From 560ab4cb4d0fba25c14f58d5cf28490f5ff5e3b2 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 17 Oct 2026 19:52:04 +0200 Subject: [PATCH] grr TUI: message cards coerce odd server values (None, numbers, lists) instead of raising; a seeded 400-message fuzz of every card kind at three widths is part of the suite Co-Authored-By: Claude Sonnet 5.5 --- tai.py | 31 ++++++++++++++++++------------- test_tai.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/tai.py b/tai.py index a9ec28d..c6f75e6 100755 --- a/tai.py +++ b/tai.py @@ -24323,6 +24323,11 @@ class GrrRouter: # --------------------------------------------------------------------------- +def grr_text(value): + """A server value as text: None is empty, anything else its string.""" + return "" if value is None else str(value) + + def grr_clip(text, width): text = str(text).replace("\n", " ") if width <= 0: @@ -28288,7 +28293,7 @@ class GrrMessageItem(GrrControlsItem): steps = (m.get("meta") or {}).get("steps") or [] if steps: self.controls.append(("steps", self.toggle_steps)) - self.controls.append(("copy", lambda: g.copy_text(m.get("content", "")))) + self.controls.append(("copy", lambda: g.copy_text(grr_text(m.get("content"))))) self.controls.append(("good", lambda: self.view.rate(m, "up"))) self.controls.append(("bad", lambda: self.view.rate(m, "down"))) if self.last: @@ -28325,7 +28330,7 @@ class GrrMessageItem(GrrControlsItem): chip = " %s %s " % (ui.icon("image"), t("chat.attachment")) out.append(grr_line([TuiSpan(" " * (width - mar - len(chip)), page), TuiSpan(chip, ui.st("soft", "bubble-user"))], width, page)) self.hit(len(out) - 1, 0, width, (lambda fid=fid: g.open_file({"id": fid, "name": "", "mime": "image/*"}))) - text = m.get("content", "") + text = grr_text(m.get("content")) if user and meta.get("routine"): text = t("chat.routine") + "\n" + text if text: @@ -28344,7 +28349,7 @@ class GrrMessageItem(GrrControlsItem): mark = ui.icon("check") if s.get("ok") else "!" line = " %s %s (%ss)" % (mark, grr_clip(s.get("summary", ""), inner - 12), s.get("secs", "")) out.append(grr_line([TuiSpan(" " * mar + line, ui.st("success" if s.get("ok") else "danger", "bg"))], width, page)) - if meta.get("smile"): + if meta.get("smile") if isinstance(meta.get("smile"), str) else None: out.append(grr_line([TuiSpan(" " * (mar + 1) + grr_clip(meta["smile"], inner - 2), ui.st("faint", "bg"))], width, page)) if (self.last or selected) and m.get("kind") == "text": glyph = {"copy": "copy", "good": "thumbup", "bad": "thumbdown", "again": "refresh"} @@ -28408,7 +28413,7 @@ class GrrApprovalItem(GrrControlsItem): bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else {"name": "Bot"}) cw = GrrCard.inner(width) lines = [[TuiSpan(" " + grr_clip(t("chat.needs_approval", name=bot["name"]), cw), ui.st("ink", "card", bold=True))]] - for line in grr_wrap(m.get("content", ""), cw - 1)[:8]: + for line in grr_wrap(grr_text(m.get("content")), cw - 1)[:8]: lines.append([TuiSpan(" " + line, ui.st("soft", "card"))]) lines.append([TuiSpan(" ", ui.st("ink", "card"))]) if meta.get("state") == "pending": @@ -28423,7 +28428,7 @@ class GrrApprovalItem(GrrControlsItem): x += len(text) + 1 lines.append(spans) else: - state = "auto" if meta.get("auto") else meta.get("state", "") + state = "auto" if meta.get("auto") else grr_text(meta.get("state")) ok = meta.get("state") == "allowed" lines.append([TuiSpan(" %s %s" % (ui.icon("check" if ok else "close"), t("chat.state_" + state)), ui.st("success" if ok else "danger", "card"))]) return grr_card_rows(ui, width, lines) @@ -28460,7 +28465,7 @@ class GrrQuestionItem(GrrControlsItem): def build(self, ui, width, selected): t = self.view.g.t cw = GrrCard.inner(width) - lines = [[TuiSpan(" " + grr_clip(self.m.get("content", ""), cw), ui.st("ink", "card", bold=True))]] + lines = [[TuiSpan(" " + grr_clip(grr_text(self.m.get("content")), cw), ui.st("ink", "card", bold=True))]] if self.answer is None: for pos, option in enumerate(self.options): on = selected and pos == self.stop @@ -28491,7 +28496,7 @@ class GrrFileMsgItem(GrrItem): def open(self): meta = self.m.get("meta") or {} - self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": self.m.get("content")}) + self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": grr_text(self.m.get("content"))}) def build(self, ui, width, selected): meta = self.m.get("meta") or {} @@ -28499,7 +28504,7 @@ class GrrFileMsgItem(GrrItem): cw = GrrCard.inner(width) mime = meta.get("mime") or "" icon = "image" if mime.startswith("image/") else ("mic" if mime.startswith("audio/") else "file") - lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(meta.get("name", ""), cw - 4)), ui.st("ink", bg, bold=True))], + lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(grr_text(meta.get("name")), cw - 4)), ui.st("ink", bg, bold=True))], [TuiSpan(" %s ยท %s" % (mime, GrrFormat.bytes(meta.get("size", 0))), ui.st("muted", bg))]] if self.m.get("content"): lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 4), ui.st("soft", bg))]) @@ -28573,8 +28578,8 @@ class GrrTemplateMsgItem(GrrControlsItem): for row in GrrAvatar.rows(ui, bot, "card"): lines.append([TuiSpan(" " * ((cw - tui_span_width(row)) // 2), ui.st("ink", "card"))] + row) badge = t("template.published") if live else t("template.unpublished") - lines.append([TuiSpan(" " + grr_clip(m.get("content", ""), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))]) - for line in grr_wrap(meta.get("description", ""), cw - 2)[:3]: + lines.append([TuiSpan(" " + grr_clip(grr_text(m.get("content")), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))]) + for line in grr_wrap(grr_text(meta.get("description")), cw - 2)[:3]: lines.append([TuiSpan(" " + line, ui.st("muted", "card"))]) spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): @@ -28794,7 +28799,7 @@ class GrrProcessItem(GrrControlsItem): pid = self.pid() term = GrrRemoteTerminal(g, "/api/processes/%s/stream?from=0" % pid, "/api/processes/%s/input" % pid, w=min(g.app.cols - 6, 100), h=min(g.app.rows - 8, 28), border=True, style=g.ui.st("ink", "sunken"), border_style=g.ui.st("accent", "bg")) - dlg = g.dialog(title=self.m.get("content", ""), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}], + dlg = g.dialog(title=grr_text(self.m.get("content")), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}], done=lambda _v, _t: term.detach()) term.on_exit = lambda: g.close_dialog(dlg, "done") term.attach() @@ -28808,7 +28813,7 @@ class GrrProcessItem(GrrControlsItem): exit_code = meta.get("exit") status = t("proc.state_" + state, code="?" if exit_code is None else exit_code) tone = "accent" if state == "running" else ("danger" if state == "exited" and exit_code else "muted") - head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(self.m.get("content", ""), cw - len(status) - 6), ui.st("ink", "card", bold=True))] + head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(grr_text(self.m.get("content")), cw - len(status) - 6), ui.st("ink", "card", bold=True))] head = grr_line(head, cw - len(status) - 1, ui.st("ink", "card")) + [TuiSpan(status + " ", ui.st(tone, "card"))] lines = [head] rows = self.ROWS_TALL if self.tall else self.ROWS @@ -29488,7 +29493,7 @@ class GrrChatView(GrrView): def rate(self, m, kind): g, t = self.g, self.g.t - body = {"text": ("[good] " if kind == "up" else "[bad] ") + m.get("content", "")[:500], "bot_id": m.get("sender")} + body = {"text": ("[good] " if kind == "up" else "[bad] ") + grr_text(m.get("content"))[:500], "bot_id": m.get("sender")} self.call("POST", "/api/feedback", body, lambda _r: g.toast(t("chat.thanks"))) def regenerate(self): diff --git a/test_tai.py b/test_tai.py index 27371c9..855ac86 100644 --- a/test_tai.py +++ b/test_tai.py @@ -4,6 +4,7 @@ import http.server import io import json import os +import random import re import shutil import socketserver @@ -11527,6 +11528,42 @@ class GrrShellTests(GrrThemeIsolation, unittest.TestCase): term.on_event("out", {"d": "aGVsbG8gYm94DQo="}) self.assertIn("hello box", app.render_text() if False else "".join("".join(c[0] if c else " " for c in row) for row in term.screen.grid)) + def test_odd_message_shapes_never_break_the_chat(self): + """Missing, empty or wrongly typed fields in what the server sends must + draw something, never raise, and never be wider than the terminal.""" + rng = random.Random(7) + words = ["a", "word", "verylongwordwithoutanyspacesatall" * 4, "emoji ๐Ÿ˜€ test", "tab\there", "line\nbreak", "", "**bold**", "`code`", "```\nfenced\n```", "- item", "> quote", "\x1b[31mred\x1b[0m"] + + def text(): + return " ".join(rng.choice(words) for _ in range(rng.randint(0, 30))) + + def maybe(value): + return rng.choice([None, "", 0, [], {}]) if rng.random() < 0.2 else value + metas = {"text": lambda: {"steps": [{"summary": maybe(text()), "ok": maybe(True), "secs": maybe(1)} for _ in range(rng.randint(0, 3))], "smile": maybe(text()), "error": maybe(True), "routine": maybe(True)}, + "approval": lambda: {"state": rng.choice(["pending", "allowed", "denied", None, "odd"]), "tool": maybe("browser"), "auto": maybe(True)}, + "question": lambda: {"options": maybe([text(), text()]), "answer": maybe(None)}, + "file": lambda: {"file_id": "f", "name": maybe(text()), "mime": maybe("image/png"), "size": maybe(10)}, + "files": lambda: {"files": [{"file_id": "f", "name": maybe(text()), "mime": maybe("x/y"), "size": maybe(3)} for _ in range(rng.randint(0, 4))]}, + "template": lambda: {"state": rng.choice(["published", None]), "description": maybe(text()), "slug": "s", "template_id": "t"}, + "process": lambda: {"pid": "pz", "state": rng.choice(["exited", "stopped", "odd"]), "exit": maybe(0), "transcript": maybe(text() + "\r\n\x1b[31mx"), "cols": 80, "rows": 24}} + msgs = [] + for n in range(400): + kind = rng.choice(list(metas)) + msgs.append({"id": 100 + n, "chat_id": "c2", "role": rng.choice(["user", "assistant"]), "kind": maybe(kind), "sender": maybe("b2"), + "content": maybe(text()), "meta": metas[kind](), "ts": rng.choice([0, 1792257000.0 + n * 3000, 1e11])}) + self.server.get["/api/chats/c2/messages"] = {"messages": msgs, "has_more": False} + try: + for cols in (30, 80, 140): + app, shell = self.make(cols, 30) + self.go(shell, app, "/chat/c2") + for _ in range(40): + shell.view.log.scroll_by(-7) + shell.view.log.move(1) + for line in self.frame(app).split("\n"): + self.assertLessEqual(len(line), cols) + finally: + self.server.get.pop("/api/chats/c2/messages", None) + def test_the_pane_shows_files_and_preview_of_a_bot_chat(self): app, shell = self.make(170, 40) self.go(shell, app, "/chat/c1")