# retoor import contextlib import io import json import os import re import shutil import sqlite3 import subprocess import sys import tempfile import time import unittest import urllib.error from datetime import datetime, timedelta, timezone from unittest import mock sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import tai VALID_SKILL = """--- name: pdf-forms description: > Fill PDF forms and extract field data. Use when the user mentions PDF documents. --- # PDF forms Do the thing. """ class SkillTests(unittest.TestCase): def write_skill(self, root, entry, body): folder = os.path.join(root, entry) os.makedirs(folder, exist_ok=True) path = os.path.join(folder, "SKILL.md") with open(path, "w", encoding="utf-8") as handle: handle.write(body) return path def test_parse_valid(self): with tempfile.TemporaryDirectory() as tmp: path = self.write_skill(tmp, "x", VALID_SKILL) skill = tai.parse_skill_file(path) self.assertEqual(skill["name"], "pdf-forms") self.assertIn("Fill PDF forms", skill["description"]) self.assertIn("Do the thing.", skill["body"]) def test_parse_rejects_bad(self): with tempfile.TemporaryDirectory() as tmp: missing = self.write_skill(tmp, "a", "---\nname: x\n---\nbody\n") self.assertIsNone(tai.parse_skill_file(missing)) bad_name = self.write_skill(tmp, "b", "---\nname: Bad_Name!\ndescription: d\n---\nbody\n") self.assertIsNone(tai.parse_skill_file(bad_name)) no_front = self.write_skill(tmp, "c", "just markdown\n") self.assertIsNone(tai.parse_skill_file(no_front)) def test_discover_project_wins(self): with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as project: self.write_skill(os.path.join(home, "skills"), "dup", VALID_SKILL) other = VALID_SKILL.replace("Do the thing.", "Project variant.") self.write_skill(os.path.join(project, ".tai", "skills"), "dup", other) found = tai.discover_skills(home, project) self.assertEqual(list(found), ["pdf-forms"]) self.assertIn("Project variant.", found["pdf-forms"]["body"]) def test_catalog(self): skills = {"b-skill": {"description": "second"}, "a-skill": {"description": "first"}} catalog = tai.skill_catalog(skills) self.assertLess(catalog.index("a-skill"), catalog.index("b-skill")) self.assertIn("load_skill", catalog) empty = tai.skill_catalog({}) self.assertIn("Buildable skill blueprints", empty) self.assertIn("bot-creator", empty) class FakeApp: def __init__(self): self.skills = {} self.env = "home" self.store = None class ToolTests(unittest.TestCase): def test_load_skill_unknown(self): tools = tai.Tools(FakeApp()) result = tools.dispatch("load_skill", json.dumps({"name": "nope"})) self.assertIn("unknown skill", result) def test_terminal_without_tmux(self): tools = tai.Tools(FakeApp()) with tempfile.TemporaryDirectory() as empty: with mock.patch.dict(os.environ, {"PATH": empty}): self.assertEqual(tools.dispatch("get_current_terminal_content", "{}"), "tmux not available") def test_box_helpers_present(self): self.assertIn("faster-whisper", tai.BOX_CONTAINERFILE) self.assertIn("edge-tts", tai.BOX_CONTAINERFILE) self.assertIn("sleep", tai.BOX_CONTAINERFILE) self.assertIn("WhisperModel", tai.BOX_STT) self.assertIn("Communicate", tai.BOX_TTS) class TerminalContextTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.hist = os.path.join(self.tmp.name, "hist") with open(self.hist, "w") as handle: handle.write("git status\ngit push\n") self.paths = mock.patch.object(tai, "shell_history_paths", return_value=[self.hist]) self.paths.start() self.which = mock.patch.object(tai.shutil, "which", return_value="/usr/bin/tmux") self.which.start() self.calls = [] def tearDown(self): self.which.stop() self.paths.stop() self.tmp.cleanup() def tmux(self, panes=(), captures=None, list_error=None): captures = captures or {} def side(argv, **kwargs): self.calls.append(list(argv)) sub = argv[1] if len(argv) > 1 else "" if sub == "display-message": return mock.Mock(returncode=0, stdout="main:0.0 bash", stderr="") if sub == "list-panes": if list_error is not None: return mock.Mock(returncode=1, stdout="", stderr=list_error) return mock.Mock(returncode=0, stdout="".join(line + "\n" for line in panes), stderr="") if sub == "capture-pane": key = argv[argv.index("-t") + 1] if "-t" in argv else "" if key in captures: out = captures[key] if isinstance(out, tuple): return mock.Mock(returncode=out[0], stdout=out[1], stderr=out[2]) return mock.Mock(returncode=0, stdout=out, stderr="") return mock.Mock(returncode=1, stdout="", stderr="can't find pane: %s" % key) raise AssertionError("unexpected tmux call %r" % (argv,)) return mock.patch.object(tai.subprocess, "run", side_effect=side) def pane(self, session, window, index, pid, active, command, path): return "\t".join([session, str(window), str(index), pid, "1" if active else "0", command, path]) def test_history_section_bash_order(self): section = tai.shell_history_section() self.assertIn("## Recent shell history", section) self.assertLess(section.index("git status"), section.index("git push")) def test_history_section_missing_files(self): with mock.patch.object(tai, "shell_history_paths", return_value=[os.path.join(self.tmp.name, "nope")]): self.assertEqual(tai.shell_history_section(), "") def test_history_zsh_extended_stripped(self): with open(self.hist, "w") as handle: handle.write(": 1690000000:0;vim foo\n: 1690000001:5;make\n") section = tai.shell_history_section() self.assertIn("vim foo", section) self.assertNotIn("1690000000", section) def test_history_fish_cmds(self): fish = os.path.join(self.tmp.name, "fish_history") with open(fish, "w") as handle: handle.write("- cmd: ls -la\n when: 1690000000\n- cmd: pwd\n when: 1690000001\n") with mock.patch.object(tai, "shell_history_paths", return_value=[fish]): section = tai.shell_history_section() self.assertIn("ls -la", section) self.assertIn("pwd", section) self.assertNotIn("when:", section) def test_history_caps_keep_newest(self): with open(self.hist, "w") as handle: for num in range(200): handle.write("cmd-%03d %s\n" % (num, "x" * 100)) section = tai.shell_history_section() self.assertLessEqual(len(section), tai.SHELL_HISTORY_CHARS + 200) self.assertIn("cmd-199", section) self.assertNotIn("cmd-000", section) def test_content_appends_history(self): tools = tai.Tools(FakeApp()) with self.tmux(captures={"": "visible pane output"}): result = tools.dispatch("get_current_terminal_content", "{}") self.assertIn("visible pane output", result) self.assertIn("## Recent shell history", result) self.assertIn("git push", result) def test_content_target_other_pane(self): tools = tai.Tools(FakeApp()) with self.tmux(captures={"%5": "other pane output"}): result = tools.dispatch("get_current_terminal_content", json.dumps({"target": "%5"})) self.assertIn("other pane output", result) self.assertIn("pane %5", result) self.assertTrue(any("-t" in call and "%5" in call for call in self.calls)) def test_content_bad_target_keeps_history(self): tools = tai.Tools(FakeApp()) with self.tmux(captures={}): result = tools.dispatch("get_current_terminal_content", json.dumps({"target": "%9"})) self.assertTrue(result.startswith("terminal capture failed")) self.assertIn("git push", result) def test_list_terminals_counts_and_marks_active(self): tools = tai.Tools(FakeApp()) panes = (self.pane("main", 0, 0, "%1", True, "bash", "/home/u"), self.pane("main", 0, 1, "%2", False, "vim", "/home/u"), self.pane("other", 1, 0, "%3", False, "python", "/tmp")) with self.tmux(panes=panes): result = tools.dispatch("list_terminals", "{}") self.assertIn("2 sessions", result) self.assertIn("2 windows", result) self.assertIn("3 panes", result) self.assertIn("main:0.0 %1 bash /home/u *", result) self.assertIn("other:1.0 %3 python /tmp", result) def test_list_terminals_no_server(self): tools = tai.Tools(FakeApp()) with self.tmux(list_error="no server running on /tmp/tmux-1000/default"): self.assertEqual(tools.dispatch("list_terminals", "{}"), "no open terminals (tmux server not running)") def test_tail_terminals_labels_each_pane(self): tools = tai.Tools(FakeApp()) panes = (self.pane("main", 0, 0, "%1", True, "bash", "/home/u"), self.pane("main", 0, 1, "%2", False, "vim", "/home/u")) with self.tmux(panes=panes, captures={"%1": "one\ntwo\nthree", "%2": "alpha\nbeta"}): result = tools.dispatch("tail_terminals", "{}") self.assertIn("=== main:0.0 %1 (bash) ===", result) self.assertIn("three", result) self.assertIn("=== main:0.1 %2 (vim) ===", result) self.assertIn("beta", result) self.assertTrue(any("-3" in call for call in self.calls)) def test_tail_terminals_clamps_lines(self): tools = tai.Tools(FakeApp()) panes = (self.pane("main", 0, 0, "%1", True, "bash", "/home/u"),) with self.tmux(panes=panes, captures={"%1": "x"}): tools.dispatch("tail_terminals", json.dumps({"lines": 99})) self.assertTrue(any("-10" in call for call in self.calls)) def test_tail_terminals_dead_pane_skipped_loudly(self): tools = tai.Tools(FakeApp()) panes = (self.pane("main", 0, 0, "%1", True, "bash", "/home/u"), self.pane("main", 0, 1, "%2", False, "vim", "/home/u")) with self.tmux(panes=panes, captures={"%1": "alive", "%2": (1, "", "can't find pane")}): result = tools.dispatch("tail_terminals", "{}") self.assertIn("alive", result) self.assertIn("(empty)", result) def test_tools_lazy_load_on_keywords(self): names = {schema["function"]["name"] for schema in tai.select_tools("list all open terminal sessions")} self.assertIn("list_terminals", names) names = {schema["function"]["name"] for schema in tai.select_tools("tail every terminal pane")} self.assertIn("tail_terminals", names) names = {schema["function"]["name"] for schema in tai.select_tools("what is in my bash history")} self.assertIn("get_current_terminal_content", names) def test_schemas_registered(self): schemas = {schema["function"]["name"]: schema["function"] for schema in tai.TOOL_SCHEMAS} self.assertIn("target", schemas["get_current_terminal_content"]["parameters"]["properties"]) self.assertIn("list_terminals", schemas) self.assertIn("lines", schemas["tail_terminals"]["parameters"]["properties"]) class AskUserTests(unittest.TestCase): def tools(self, **attrs): app = FakeApp() for key, value in attrs.items(): setattr(app, key, value) return tai.Tools(app) def tty(self): fake_stdin = io.StringIO("") fake_stdin.isatty = lambda: True return mock.patch.object(sys, "stdin", fake_stdin) def test_schema_registered_and_core(self): schemas = {schema["function"]["name"]: schema["function"] for schema in tai.TOOL_SCHEMAS} self.assertIn("ask_user", schemas) self.assertIn("question", schemas["ask_user"]["parameters"]["required"]) self.assertIn("ask_user", tai.CORE_TOOLS) self.assertIn("question", tai.TOOL_TAGS["ask_user"]) self.assertIn("ask_user", {schema["function"]["name"] for schema in tai.select_tools("hello")}) def test_system_prompt_points_at_tool(self): self.assertIn("ask_user", tai.DEFAULT_SYSTEM) self.assertIn("ask_user", tai.AUTONOMOUS_NOTE) def test_validation(self): tools = self.tools() self.assertIn("non-empty string", tools.dispatch("ask_user", json.dumps({}))) self.assertIn("non-empty string", tools.dispatch("ask_user", json.dumps({"question": " "}))) self.assertIn("must be a list", tools.dispatch("ask_user", json.dumps({"question": "q?", "options": "nope"}))) def test_free_input(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", return_value="do the simple thing") as asked, mock.patch("builtins.print") as shown: result = tools.dispatch("ask_user", json.dumps({"question": "Which approach?"})) self.assertEqual(result, "user answered: do the simple thing") self.assertTrue(tools.app.user_stepped_in) asked.assert_called_once() self.assertIn("free input", asked.call_args.args[0]) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("Which approach?", printed) def test_option_by_number(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", return_value="2"), mock.patch("builtins.print") as shown: result = tools.dispatch("ask_user", json.dumps({"question": "Pick one", "options": ["Ship it", "Staging first", "Abort"]})) self.assertEqual(result, "user chose option 2: Staging first") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("1) Ship it", printed) self.assertIn("3) Abort", printed) def test_option_by_text_case_insensitive(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", return_value="staging FIRST"), mock.patch("builtins.print"): result = tools.dispatch("ask_user", json.dumps({"question": "Pick one", "options": ["Ship it", "Staging first"]})) self.assertEqual(result, "user chose option 2: Staging first") def test_out_of_range_number_is_free_text(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", return_value="9"), mock.patch("builtins.print"): result = tools.dispatch("ask_user", json.dumps({"question": "Pick one", "options": ["A", "B"]})) self.assertEqual(result, "user answered: 9") def test_empty_answer_keeps_turn_alive(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", return_value=" "), mock.patch("builtins.print"): result = tools.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("no answer (empty)", result) self.assertIn("proceed", result) def test_eof_keeps_turn_alive(self): tools = self.tools() with self.tty(), mock.patch("builtins.input", side_effect=EOFError), mock.patch("builtins.print"): result = tools.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("no answer (input closed)", result) def test_non_tty_refuses_without_blocking(self): tools = self.tools() fake_stdin = io.StringIO("") fake_stdin.isatty = lambda: False with mock.patch.object(sys, "stdin", fake_stdin), mock.patch("builtins.input") as asked: result = tools.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("no interactive user", result) asked.assert_not_called() def test_auto_mode_refuses(self): tools = self.tools(auto=True) with mock.patch("builtins.input") as asked: result = tools.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("autonomous mode", result) asked.assert_not_called() def test_background_worker_refuses(self): tools = self.tools(persist=False) with mock.patch("builtins.input") as asked: result = tools.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("background workers", result) asked.assert_not_called() deep = self.tools(persist=True, depth=1) with mock.patch("builtins.input") as asked: result = deep.dispatch("ask_user", json.dumps({"question": "q?"})) self.assertIn("background workers", result) asked.assert_not_called() def test_options_capped(self): tools = self.tools() many = ["opt%d" % num for num in range(20)] with self.tty(), mock.patch("builtins.input", return_value="1"), mock.patch("builtins.print") as shown: result = tools.dispatch("ask_user", json.dumps({"question": "q?", "options": many})) self.assertEqual(result, "user chose option 1: opt0") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("12) opt11", printed) self.assertNotIn("13) opt12", printed) class SysinfoTests(unittest.TestCase): def test_collect_all_checks_visible(self): report = tai.collect_sysinfo() lines = report.splitlines() self.assertEqual(len(lines), len(tai.SYSINFO_CHECKS)) for (name, _func), line in zip(tai.SYSINFO_CHECKS, lines): self.assertTrue(line.startswith(name + ": "), line) self.assertRegex(line, r"\(\d+ms\)$") def test_subset(self): report = tai.collect_sysinfo(["os", "root"]) lines = report.splitlines() self.assertEqual(len(lines), 2) self.assertTrue(lines[0].startswith("os: ")) self.assertTrue(lines[1].startswith("root: ")) def test_checks_run_in_parallel(self): def slow(): time.sleep(0.3) return "ok" stubs = tuple(("slow%d" % pos, slow) for pos in range(4)) with mock.patch("tai.SYSINFO_CHECKS", stubs): started = time.time() report = tai.collect_sysinfo() self.assertLess(time.time() - started, 1.0) self.assertEqual(len(report.splitlines()), 4) def test_timeout_surfaces(self): def stuck(): time.sleep(5) return "never" with mock.patch("tai.SYSINFO_CHECKS", (("stuck", stuck),)): with mock.patch.object(tai.Consts, "SYSINFO_TIMEOUT", 1): report = tai.collect_sysinfo() self.assertIn("stuck: timed out after 1s", report) def test_check_error_surfaces(self): def broken(): raise RuntimeError("boom") with mock.patch("tai.SYSINFO_CHECKS", (("broken", broken),)): report = tai.collect_sysinfo() self.assertIn("broken: error: boom", report) def test_venv_detection(self): with mock.patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/fake-venv"}): self.assertIn("/tmp/fake-venv", tai.sysinfo_check_venv()) def test_root_shape(self): result = tai.sysinfo_check_root() self.assertTrue(result.startswith(("yes", "no", "unknown")), result) def test_tool_dispatch(self): tools = tai.Tools(FakeApp()) self.assertIn("os: ", tools.dispatch("sysinfo", "{}")) subset = tools.dispatch("sysinfo", json.dumps({"checks": ["python"]})) self.assertEqual(len(subset.splitlines()), 1) self.assertIn("unknown checks: nope", tools.dispatch("sysinfo", json.dumps({"checks": ["nope"]}))) self.assertIn("non-empty list", tools.dispatch("sysinfo", json.dumps({"checks": []}))) def test_sysinfo_command(self): self.assertIn("sysinfo", tai.COMMANDS) self.assertIn("/sysinfo", tai.HELP_TEXT) with mock.patch.object(tai.SysInfo, "collect_sysinfo", return_value="fake-report") as collector: with mock.patch("builtins.print") as printer: self.assertTrue(tai.handle_command(mock.Mock(), "/sysinfo")) collector.assert_called_once_with() printer.assert_called_once_with("fake-report") class FakeSkillApp: def __init__(self, config): self.depth = 0 self.profile = "t" self.config = config self.store = mock.Mock() self.store.redact = lambda text, profile=None: text self.skills = {} self.applied = 0 self.runner_override = None def apply_system(self): self.applied += 1 class CreateSkillTests(unittest.TestCase): def config_in(self, tmp): old_home = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp class FakeArgs: profile = "t" yes = True try: return tai.Config(FakeArgs()) finally: if old_home is None: os.environ.pop("TAI_HOME", None) else: os.environ["TAI_HOME"] = old_home def test_prompt_is_self_contained(self): prompt = tai.create_skill_prompt("pdf-forms", "fill pdf forms", "/tmp/x/pdf-forms") self.assertIn("pdf-forms", prompt) self.assertIn("fill pdf forms", prompt) self.assertIn("/tmp/x/pdf-forms", prompt) self.assertIn("sysinfo", prompt) self.assertIn("SKILL.md", prompt) self.assertIn("name: pdf-forms", prompt) self.assertIn("two independent sources", prompt) def test_validation(self): tools = tai.Tools(FakeSkillApp(None)) self.assertIn("invalid skill name", tools.dispatch("create_skill", json.dumps({"name": "Bad_Name!", "brief": "b"}))) self.assertIn("invalid skill name", tools.dispatch("create_skill", json.dumps({"brief": "b"}))) self.assertIn("empty brief", tools.dispatch("create_skill", json.dumps({"name": "ok-name"}))) self.assertIn("scope must be", tools.dispatch("create_skill", json.dumps({"name": "ok-name", "brief": "b", "scope": "moon"}))) deep = FakeSkillApp(None) deep.depth = 2 self.assertIn("depth limit", tai.Tools(deep).dispatch("create_skill", json.dumps({"name": "ok-name", "brief": "b"}))) def test_creates_and_refreshes(self): with tempfile.TemporaryDirectory() as tmp: app = FakeSkillApp(self.config_in(tmp)) seen = {} def runner(task, profile, timeout): seen["task"] = task seen["profile"] = profile seen["timeout"] = timeout skill_dir = os.path.join(tmp, ".tai", "skills", "pdf-forms") os.makedirs(skill_dir, exist_ok=True) with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: handle.write(VALID_SKILL) return "researched and wrote the skill" app.runner_override = runner with mock.patch("os.getcwd", return_value=tmp): result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "pdf-forms", "brief": "fill pdf forms"})) self.assertIn("created at", result) self.assertIn("pdf-forms", app.skills) self.assertEqual(app.applied, 1) self.assertEqual(seen["profile"], "t") self.assertIn("fill pdf forms", seen["task"]) self.assertIn("SKILL.md", seen["task"]) def test_replace_and_missing(self): with tempfile.TemporaryDirectory() as tmp: config = self.config_in(tmp) skill_dir = os.path.join(tmp, ".tai", "skills", "pdf-forms") os.makedirs(skill_dir, exist_ok=True) with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: handle.write(VALID_SKILL) app = FakeSkillApp(config) app.runner_override = lambda task, profile, timeout: "rewrote it" with mock.patch("os.getcwd", return_value=tmp): result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "pdf-forms", "brief": "b"})) self.assertIn("replaced at", result) app2 = FakeSkillApp(config) app2.runner_override = lambda task, profile, timeout: "gave up" with mock.patch("os.getcwd", return_value=tmp): result2 = tai.Tools(app2).dispatch("create_skill", json.dumps({"name": "other-skill", "brief": "b"})) self.assertIn("was not created (done)", result2) self.assertNotIn("other-skill", app2.skills) self.assertEqual(app2.applied, 0) def test_home_scope(self): with tempfile.TemporaryDirectory() as tmp: app = FakeSkillApp(self.config_in(tmp)) def runner(task, profile, timeout): skill_dir = os.path.join(tmp, "skills", "home-skill") os.makedirs(skill_dir, exist_ok=True) with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: handle.write(VALID_SKILL.replace("pdf-forms", "home-skill")) return "done" app.runner_override = runner with mock.patch("os.getcwd", return_value="/nonexistent-dir"): result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "home-skill", "brief": "b", "scope": "home"})) self.assertIn("created at", result) self.assertIn("home-skill", app.skills) class FakeSecretApp: def __init__(self, store, approve=True): self.store = store self.env = "home" self.config = mock.Mock(auto_approve=True) self.approvals = [] self.approve = approve def ask_approval(self, command): self.approvals.append(command) return self.approve def show_diff(self): pass class SecretsTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) self.store = tai.Store(config, tai.Seal(config.home, "vault-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_store_list_delete_roundtrip(self): app = FakeSecretApp(self.store) tools = tai.Tools(app) self.assertIn("stored secret 'api'", tools.dispatch("store_secret", json.dumps({"name": "api", "value": "token-abc-123"}))) self.assertEqual(self.store.load_secret("api"), "token-abc-123") raw = self.store.db.execute("SELECT value FROM secrets WHERE name = 'api'").fetchone()[0] self.assertTrue(raw.startswith("tai1$")) self.assertNotIn("token-abc", raw) listed = tools.dispatch("list_secrets", "{}") self.assertIn("api", listed) self.assertNotIn("token-abc", listed) self.assertIn("deleted secret 'api'", tools.dispatch("delete_secret", json.dumps({"name": "api"}))) self.assertIsNone(self.store.load_secret("api")) self.assertEqual(len(app.approvals), 1) def test_validation(self): tools = tai.Tools(FakeSecretApp(self.store)) self.assertIn("invalid secret name", tools.dispatch("store_secret", json.dumps({"name": "Bad Name!", "value": "x"}))) self.assertIn("empty value", tools.dispatch("store_secret", json.dumps({"name": "ok", "value": ""}))) self.assertIn("unknown secret", tools.dispatch("delete_secret", json.dumps({"name": "nope"}))) self.assertIn("unknown tool", tools.dispatch("get_secret", json.dumps({"name": "x"}))) def test_shell_blind_injection(self): self.store.save_secret("demo", "injected-value-42") app = FakeSecretApp(self.store) tools = tai.Tools(app) result = tools.dispatch("shell", json.dumps({"command": "echo $TAI_SECRET_DEMO", "secrets": ["demo"]})) self.assertIn("[redacted:demo]", result) self.assertNotIn("injected-value-42", result) tools.dispatch("shell", json.dumps({"command": "echo $TAI_SECRET_DEMO", "secrets": ["demo"]})) self.assertEqual(len(app.approvals), 1) self.assertIn("unknown secret 'nope'", tools.dispatch("shell", json.dumps({"command": "echo hi", "secrets": ["nope"]}))) self.assertIn("must be a list", tools.dispatch("shell", json.dumps({"command": "echo hi", "secrets": "demo"}))) def test_shell_secret_denied(self): self.store.save_secret("demo", "injected-value-42") app = FakeSecretApp(self.store, approve=False) result = tai.Tools(app).dispatch("shell", json.dumps({"command": "echo hi", "secrets": ["demo"]})) self.assertEqual(result, "denied by user") def test_web_fetch_auth(self): self.store.save_secret("api", "fetch-token-7") seen = {} class FakeResponse: def __enter__(self): return self def __exit__(self, *exc): return False def read(self, limit=0): return b"ok fetch-token-7 here" def fake_urlopen(request, timeout=30): seen[request.full_url] = {key.lower(): value for key, value in request.headers.items()} return FakeResponse() app = FakeSecretApp(self.store) tools = tai.Tools(app) with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): result = tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "api"})) keyed = tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v2", "auth_secret": "api", "auth_header": "X-Api-Key", "auth_prefix": ""})) first = seen["https://api.example.test/v1"] second = seen["https://api.example.test/v2"] self.assertEqual(first["authorization"], "Bearer fetch-token-7") self.assertIn("tai/", first["user-agent"]) self.assertEqual(second["x-api-key"], "fetch-token-7") self.assertIn("[redacted:api]", result) self.assertNotIn("fetch-token-7", result) self.assertIn("[redacted:api]", keyed) self.assertEqual(len(app.approvals), 1) self.assertIn("unknown secret", tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "nope"}))) self.assertIn("invalid auth header", tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "api", "auth_header": "Bad\nHeader"}))) def test_redact(self): self.store.save_secret("long", "abcdefghij") self.store.save_secret("short", "abc") self.store.save_secret("sub", "cdef") text = self.store.redact("see abcdefghij and abc here") self.assertEqual(text, "see [redacted:long] and abc here") def test_run_turn_scrubs_and_redacts(self): class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) agent = tai.Agent(config, self.store, persist=False, quiet=True) call_reply = {"role": "assistant", "content": "storing now hunter2-leak", "reasoning": "", "tool_calls": [{"id": "c9", "name": "store_secret", "arguments": json.dumps({"name": "leak", "value": "hunter2-leak"})}], "backend": "x"} final_reply = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", side_effect=[call_reply, final_reply]): result = agent.run_turn("remember it", capture=True) self.assertEqual(result, "done") blob = json.dumps(agent.messages) self.assertNotIn("hunter2-leak", blob) self.assertIn("[redacted:leak]", blob) self.assertEqual(self.store.load_secret("leak"), "hunter2-leak") self.assertEqual(self.store.search_events("t", "hunter2"), []) def test_secret_repl(self): agent = mock.Mock() agent.store = self.store agent.profile = "t" agent.ask_approval = lambda command, guidance=True: True agent.tools = tai.Tools(agent) with mock.patch("getpass.getpass", return_value="repl-value-1"): with mock.patch("builtins.print") as printer: self.assertTrue(tai.handle_command(agent, "/secret set wifi")) self.assertEqual(self.store.load_secret("wifi"), "repl-value-1") printed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("stored secret 'wifi'", printed) self.assertNotIn("repl-value-1", printed) with mock.patch("builtins.print") as printer: tai.handle_command(agent, "/secret list") tai.handle_command(agent, "/secret") listed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("- wifi", listed) self.assertNotIn("repl-value-1", listed) with mock.patch("builtins.print") as printer: tai.handle_command(agent, "/secret delete wifi") tai.handle_command(agent, "/secret delete nope") tai.handle_command(agent, "/secret frobnicate") tai.handle_command(agent, "/secret set Bad Name") removed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("deleted secret 'wifi'", removed) self.assertIn("unknown secret", removed) self.assertIn("use /secret set", removed) self.assertIn("invalid secret name", removed) self.assertIsNone(self.store.load_secret("wifi")) def test_legacy_memory_migrates_to_vault(self): self.assertIn("store_secret", tai.DEFAULT_SYSTEM) self.assertNotIn("collect and keep", tai.DEFAULT_SYSTEM) class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) old_system = "You are tai. Memory: call remember whenever you learn durable facts, " + tai.LEGACY_PASSWORD_NOTE + "; call it with a forget instruction." self.store.save_system("old", old_system) agent = tai.Agent(config, self.store, persist=False, quiet=True) agent.switch_profile("old", silent=True) self.assertNotIn(tai.LEGACY_PASSWORD_NOTE, agent.system_message) self.assertIn("store_secret", agent.system_message) self.assertIn("store_secret", self.store.load_system("old")) class FakeSchedulerApp: def __init__(self, store, approve=True): self.store = store self.profile = "t" self.approvals = [] self.approve = approve def ask_approval(self, command): self.approvals.append(command) return self.approve class SchedulerTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.seal = tai.Seal(self.config.home, "sched-test-1") self.store = tai.Store(self.config, self.seal) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def test_parse_at_and_duration(self): parsed = tai.parse_schedule_at("2026-10-08T09:00") self.assertEqual(datetime.fromisoformat(parsed).utcoffset().total_seconds(), 0) self.assertEqual(tai.local_display(parsed), "2026-10-08 09:00") with self.assertRaises(ValueError): tai.parse_schedule_at("not a date") self.assertEqual(tai.parse_duration("10m"), 600) self.assertEqual(tai.parse_duration("2h"), 7200) self.assertEqual(tai.parse_duration("1d"), 86400) self.assertEqual(tai.parse_duration("45"), 45) self.assertIsNone(tai.parse_duration("nope")) self.assertEqual(tai.format_delay(4000), "1h") self.assertEqual(tai.format_delay(-5), "overdue") def test_schedule_validation(self): tools = tai.Tools(FakeSchedulerApp(self.store)) self.assertIn("empty prompt", tools.dispatch("schedule", json.dumps({"at": "2026-10-08T09:00"}))) self.assertIn("exactly one", tools.dispatch("schedule", json.dumps({"prompt": "x"}))) self.assertIn("exactly one", tools.dispatch("schedule", json.dumps({"prompt": "x", "at": "2026-10-08T09:00", "every": 60}))) self.assertIn("invalid datetime", tools.dispatch("schedule", json.dumps({"prompt": "x", "at": "soon"}))) self.assertIn("at least 60", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 5}))) self.assertIn("invalid profile", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 60, "profile": "bad name!"}))) denied = tai.Tools(FakeSchedulerApp(self.store, approve=False)) self.assertEqual(denied.dispatch("schedule", json.dumps({"prompt": "x", "every": 60})), "denied by user") self.assertEqual(self.store.list_schedules(), []) def test_schedule_roundtrip(self): app = FakeSchedulerApp(self.store) tools = tai.Tools(app) once = tools.dispatch("schedule", json.dumps({"prompt": "water plants", "name": "plants", "at": "2026-10-08T09:00"})) self.assertIn("scheduled #1", once) rep = tools.dispatch("schedule", json.dumps({"prompt": "check mail", "every": 3600})) self.assertIn("scheduled #2", rep) raw = self.store.db.execute("SELECT prompt FROM schedules WHERE id = 1").fetchone()[0] self.assertTrue(raw.startswith("tai1$")) listed = tools.dispatch("schedules", "{}") self.assertIn("#1", listed) self.assertIn("plants", listed) self.assertIn("every 1h", listed) self.assertIn("water plants", listed) self.assertIn("deleted schedule #1", tools.dispatch("unschedule", json.dumps({"id": 1}))) self.assertIn("no schedule #1", tools.dispatch("unschedule", json.dumps({"id": 1}))) self.assertEqual(len(app.approvals), 3) def test_tick_fires_once_async(self): self.store.add_schedule("job", "do the thing", "t", 0, "2020-01-01T00:00:00+00:00", 60) calls = [] def stub(task, profile, timeout): calls.append((task, profile, timeout)) return "stub-result" self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=stub), 1) self.assertEqual(calls[0][0], "do the thing") self.assertEqual(self.store.db.execute("SELECT status FROM schedules WHERE id = 1").fetchone()[0], "done") self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=stub), 0) self.assertEqual(len(calls), 1) deadline = time.time() + 5 row = ("", "") while time.time() < deadline: row = self.store.db.execute("SELECT last_status, last_result FROM schedules WHERE id = 1").fetchone() if row[0]: break time.sleep(0.05) self.assertEqual(row[0], "done") self.assertIn("stub-result", row[1]) def test_tick_repeat_advances_without_backfill(self): past = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() self.store.add_schedule("hourly", "ping", "t", 3600, past, 60) calls = [] self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=lambda task, profile, timeout: calls.append(task) or "ok"), 1) self.assertEqual(len(calls), 1) row = self.store.db.execute("SELECT status, next_run FROM schedules WHERE id = 1").fetchone() self.assertEqual(row[0], "pending") self.assertGreater(datetime.fromisoformat(row[1]), datetime.now(timezone.utc)) def test_tick_skips_claimed(self): self.store.add_schedule("job", "do it", "t", 0, "2020-01-01T00:00:00+00:00", 60) self.store.db.execute("UPDATE schedules SET status = 'done' WHERE id = 1") self.store.db.commit() calls = [] self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=lambda task, profile, timeout: calls.append(task) or "ok"), 0) self.assertEqual(calls, []) def test_schedule_repl(self): agent = mock.Mock() agent.store = self.store agent.profile = "t" with mock.patch("builtins.print") as printer: tai.handle_command(agent, "/schedule every 1h check mail") tai.handle_command(agent, "/schedule at 2026-10-08T09:00 water plants") created = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("scheduled #1", created) self.assertIn("scheduled #2", created) with mock.patch("builtins.print") as printer: tai.handle_command(agent, "/schedules") listed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("#1", listed) self.assertIn("check mail", listed) with mock.patch("builtins.print") as printer: tai.handle_command(agent, "/schedule soon x") tai.handle_command(agent, "/schedule every 5s x") tai.handle_command(agent, "/schedule at nope x") tai.handle_command(agent, "/unschedule 1") tai.handle_command(agent, "/unschedule 1") tai.handle_command(agent, "/unschedule x") errors = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("use /schedule at", errors) self.assertIn("at least 60", errors) self.assertIn("invalid datetime", errors) self.assertIn("deleted schedule #1", errors) self.assertIn("no schedule #1", errors) self.assertIn("use /unschedule", errors) def test_delegation_wording(self): self.assertIn("Delegation:", tai.DEFAULT_SYSTEM) self.assertIn("fork background subagents", tai.DEFAULT_SYSTEM) self.assertIn("schedule future work", tai.DEFAULT_SYSTEM) class RecordGraphTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "record-test-1")) self.app = FakeSecretApp(self.store) self.tools = tai.Tools(self.app) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def mem_id(self, text): match = re.search(r"mem:[0-9a-f]{16}", text) self.assertIsNotNone(match, "no mem id in %r" % text) return match.group(0) def test_save_read_search_delete_roundtrip(self): saved = self.tools.dispatch("record_save", json.dumps({"title": "site notes", "content": "alpha beta gamma", "kind": "note", "tags": ["Site Visit"]})) record_id = self.mem_id(saved) self.assertIn("16 chars", saved) self.assertIn("site-visit", saved) page = self.tools.dispatch("record_read", json.dumps({"id": record_id})) self.assertIn("chars 0-16 of 16", page) self.assertIn("alpha beta gamma", page) found = self.tools.dispatch("record_search", json.dumps({"query": "beta"})) self.assertIn(record_id, found) self.assertIn("site notes", found) deleted = self.tools.dispatch("record_delete", json.dumps({"id": record_id})) self.assertIn("deleted record", deleted) self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": record_id}))) self.assertEqual(self.app.approvals, ["delete record " + record_id]) def test_read_pages_offsets(self): saved = self.tools.dispatch("record_save", json.dumps({"title": "paged", "content": "".join("%04d" % num for num in range(250))})) record_id = self.mem_id(saved) first = self.tools.dispatch("record_read", json.dumps({"id": record_id, "limit": 100})) self.assertIn("chars 0-100 of 1000", first) second = self.tools.dispatch("record_read", json.dumps({"id": record_id, "offset": 100, "limit": 100})) self.assertIn("chars 100-200 of 1000", second) self.assertNotEqual(first.splitlines()[-1], second.splitlines()[-1]) record = self.store.get_record(record_id) self.assertGreaterEqual(record["reads"], 3) def test_record_validation_errors(self): self.assertIn("content is empty", self.tools.dispatch("record_save", json.dumps({"title": "x"}))) self.assertIn("unknown kind", self.tools.dispatch("record_save", json.dumps({"content": "x", "kind": "song"}))) self.assertIn("tags must be a list", self.tools.dispatch("record_save", json.dumps({"content": "x", "tags": "nope"}))) self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": "mem:0123456789abcdef"}))) self.assertIn("no records match", self.tools.dispatch("record_search", json.dumps({"query": "nothing-here-zzz"}))) def test_delete_denied_keeps_record(self): saved = self.tools.dispatch("record_save", json.dumps({"content": "keep me"})) record_id = self.mem_id(saved) self.app.approve = False self.assertIn("denied by user", self.tools.dispatch("record_delete", json.dumps({"id": record_id}))) self.assertIsNotNone(self.store.get_record(record_id)) def test_search_filters_kind_and_tags(self): self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "shared word", "kind": "note", "tags": ["team"]})) self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "shared word", "kind": "output", "tags": ["team"]})) by_kind = self.tools.dispatch("record_search", json.dumps({"query": "shared", "kind": "output"})) self.assertIn("[output]", by_kind) self.assertNotIn("[note]", by_kind) by_tag = self.tools.dispatch("record_search", json.dumps({"tags": ["team"]})) self.assertIn("[output]", by_tag) self.assertIn("[note]", by_tag) missing = self.tools.dispatch("record_search", json.dumps({"tags": ["team", "other"]})) self.assertIn("no records match", missing) def test_graph_link_and_query(self): first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "plan", "content": "the plan"}))) second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "bravo-log-title", "content": "the log"}))) self.tools.dispatch("store_secret", json.dumps({"name": "deploy", "value": "token-xyz-9"})) linked = self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": second, "relation": "Follows Up"})) self.assertIn("follows-up", linked) self.tools.dispatch("graph_link", json.dumps({"src": second, "dst": "secret:deploy", "relation": "uses"})) view = self.tools.dispatch("graph_query", json.dumps({"node": first})) self.assertIn(first + " :: plan", view) self.assertIn(second, view) self.assertIn("secret:deploy", view) self.assertIn("bravo-log-title", view) self.assertIn("unknown node", self.tools.dispatch("graph_query", json.dumps({"node": "mem:0123456789abcdef"}))) self.assertIn("unknown node", self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": "mem:0123456789abcdef"}))) def test_traverse_caps_depth(self): ids = [self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "n%d" % num, "content": "x"}))) for num in range(6)] for pos in range(5): self.store.add_edge(ids[pos], ids[pos + 1], "next") shallow = self.store.traverse(ids[0], depth=1, limit=50) self.assertEqual(len(shallow), 2) deep = self.store.traverse(ids[0], depth=99, limit=50) self.assertEqual(len(deep), 5) self.assertEqual(max(item["depth"] for item in deep), 4) def test_shell_output_spills_to_record(self): result = self.tools.dispatch("shell", json.dumps({"command": "python3 -c \"print('0123456789' * 700)\""})) self.assertIn("exit 0", result) record_id = self.mem_id(result) self.assertIn("record_read pages the rest", result) page = self.tools.dispatch("record_read", json.dumps({"id": record_id, "limit": 20})) self.assertIn("of 7000", page) found = self.tools.dispatch("record_search", json.dumps({"tags": ["shell"]})) self.assertIn(record_id, found) def test_poll_spills_once_and_reuses(self): big = "z" * 7000 agent_id = tai.spawn_agent("big task", "t", 60, None, None, 0, lambda task, profile, timeout: big) first = self.tools.dispatch("poll", json.dumps({"id": agent_id, "wait": 5})) record_id = self.mem_id(first) second = self.tools.dispatch("poll", json.dumps({"id": agent_id})) self.assertIn(record_id, second) rows = self.store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0] self.assertEqual(rows, 1) def test_record_repl_commands(self): record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "repl note", "content": "visible words"}))) self.app.tools = self.tools with mock.patch("builtins.print") as shown: tai.handle_command(self.app, "/records visible") tai.handle_command(self.app, "/record " + record_id) tai.handle_command(self.app, "/graph " + record_id) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn(record_id, printed) self.assertIn("repl note", printed) def test_wal_mode_enabled(self): mode = self.store.db.execute("PRAGMA journal_mode").fetchone()[0] self.assertEqual(mode.lower(), "wal") class SecretMetaTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) self.store = tai.Store(config, tai.Seal(config.home, "meta-test-1")) self.tools = tai.Tools(FakeSecretApp(self.store)) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_metadata_roundtrip(self): result = self.tools.dispatch("store_secret", json.dumps({"name": "db", "value": "pw-12345", "username": "ops", "host": "db.internal", "port": 5432, "notes": "primary", "tags": ["Prod DB"]})) self.assertIn("stored secret 'db'", result) listed = self.tools.dispatch("list_secrets", "{}") self.assertIn("ops@db.internal:5432", listed) self.assertIn("prod-db", listed) self.assertNotIn("pw-12345", listed) infos = self.store.list_secret_infos() self.assertEqual(infos[0]["meta"].get("username"), "ops") self.assertEqual(infos[0]["meta"].get("port"), 5432) def test_minimum_info_suffices(self): result = self.tools.dispatch("store_secret", json.dumps({"name": "plain", "value": "v-abcdef"})) self.assertIn("stored secret 'plain'", result) self.assertIn("- plain [secret]", self.tools.dispatch("list_secrets", "{}")) class ScheduleTagTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "schetag-1")) self.tools = tai.Tools(FakeSchedulerApp(self.store)) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_schedule_tags_shown(self): self.tools.dispatch("schedule", json.dumps({"prompt": "water plants", "every": 3600, "tags": ["Home Chores"]})) listed = self.tools.dispatch("schedules", "{}") self.assertIn("home-chores", listed) items = self.store.list_schedules() self.assertIn("schedule", items[0]["tags"]) class ModeTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = False self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "mode-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_interactive_always_enables_yolo(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) with mock.patch("sys.stdin") as fake_stdin: fake_stdin.isatty.return_value = True with mock.patch("builtins.input", return_value="Y"): with mock.patch("builtins.print"): self.assertTrue(agent.ask_approval("do it")) self.assertTrue(agent.yolo) with mock.patch("builtins.input", side_effect=AssertionError("must not ask")): self.assertTrue(agent.ask_approval("do it again")) def test_yolo_never_asks(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True, yolo=True) with mock.patch("builtins.input", side_effect=AssertionError("must not ask")): self.assertTrue(agent.ask_approval("anything")) def test_auto_gets_autonomous_note(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True, auto=True) agent.apply_system() self.assertIn("Autonomous mode", agent.messages[0]["content"]) class AuditTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "audit-test-1")) self.app = FakeSecretApp(self.store) self.app.skills = {} self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def workfile(self, name="note.txt", content="v1"): path = os.path.join(self.tmp.name, name) with open(path, "w", encoding="utf-8") as handle: handle.write(content) return path def test_write_audits_and_records(self): path = os.path.join(self.tmp.name, "fresh.txt") self.assertIn("wrote", self.tools.dispatch("write_file", json.dumps({"path": path, "content": "hello"}))) rows = self.store.audit_history(path) self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["action"], "write") self.assertIsNone(rows[0]["old_size"]) self.assertEqual(rows[0]["new_size"], 5) record_id = self.store.file_record_id(path) self.assertIsNotNone(record_id) found = self.tools.dispatch("record_search", json.dumps({"tags": ["txt"]})) self.assertIn(record_id, found) self.tools.dispatch("read_file", json.dumps({"path": path})) self.tools.dispatch("write_file", json.dumps({"path": path, "content": "hello again"})) rows = self.store.audit_history(path) self.assertEqual(len(rows), 2) self.assertEqual(rows[0]["old_size"], 5) self.assertEqual(rows[0]["new_size"], 11) def test_edit_captures_old_and_new(self): path = self.workfile() self.tools.dispatch("read_file", json.dumps({"path": path})) self.assertIn("edited", self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "v1", "replace": "v2"}))) row = self.store.audit_get(self.store.audit_history(path)[0]["id"]) self.assertEqual(row["action"], "edit") self.assertEqual(row["old"], "v1") self.assertEqual(row["new"], "v2") def test_big_file_truncates_with_marker(self): path = os.path.join(self.tmp.name, "big.txt") content = "x" * 60000 self.tools.dispatch("write_file", json.dumps({"path": path, "content": content})) row = self.store.audit_get(self.store.audit_history(path)[0]["id"]) self.assertEqual(row["new_size"], 60000) self.assertIn("[truncated, full size 60000 bytes]", row["new"]) record = self.store.get_record(self.store.file_record_id(path)) self.assertIn("[truncated, full size 60000 bytes]", record["content"]) def test_shell_rm_snapshots_before_delete(self): first = self.workfile("a.txt", "alpha") second = self.workfile("b.txt", "beta") result = self.tools.dispatch("shell", json.dumps({"command": "rm a.txt b.txt", "workdir": self.tmp.name})) self.assertIn("exit 0", result) self.assertFalse(os.path.exists(first)) snaps = self.store.audit_history(tag="shell") self.assertEqual(len(snaps), 2) by_path = {row["path"]: row for row in snaps} full = self.store.audit_get(by_path[first]["id"]) self.assertEqual(full["old"], "alpha") self.assertIsNone(full["new"]) self.assertIn("rm a.txt b.txt", full["message"]) def test_shell_redirect_snapshots_target(self): path = self.workfile("out.txt", "old words") self.tools.dispatch("shell", json.dumps({"command": "echo new > out.txt", "workdir": self.tmp.name})) snaps = self.store.audit_history(tag="shell") self.assertEqual(len(snaps), 1) full = self.store.audit_get(snaps[0]["id"]) self.assertEqual(full["old"], "old words") def test_shell_parser_units(self): self.assertEqual(tai.segment_targets(["rm", "-rf", "a", "b"]), ["a", "b"]) self.assertEqual(tai.segment_targets(["sudo", "rm", "x"]), ["x"]) self.assertEqual(tai.segment_targets(["VAR=1", "cmd", ">", "out"]), ["out"]) self.assertEqual(tai.segment_targets(["tee", "t1", "t2"]), ["t1", "t2"]) self.assertEqual(tai.segment_targets(["mv", "a", "b"]), ["b"]) self.assertEqual(tai.segment_targets(["cmd", "2>", "/dev/null"]), []) self.assertEqual(tai.segment_targets(["truncate", "-s", "0", "f"]), ["f"]) self.assertEqual(tai.segment_targets(["dd", "if=a", "of=b"]), ["b"]) sub = os.path.join(self.tmp.name, "sub") os.makedirs(sub) self.workfile("sub/one.txt", "1") self.workfile("sub/two.txt", "2") hits = tai.shell_target_paths("rm -rf sub", self.tmp.name) self.assertEqual(len(hits), 2) first = self.workfile("t1.txt", "1") piped = tai.shell_target_paths("echo x | tee t1.txt missing.txt", self.tmp.name) self.assertEqual(piped, [first]) def test_delete_roundtrip_and_deny(self): gone = self.workfile("gone.txt", "bye") kept = self.workfile("kept.txt", "hi") self.tools.dispatch("read_file", json.dumps({"path": gone})) self.tools.dispatch("read_file", json.dumps({"path": kept})) self.assertIn("deleted", self.tools.dispatch("delete_file", json.dumps({"path": gone}))) self.assertFalse(os.path.exists(gone)) self.assertIsNone(self.store.file_record_id(gone)) row = self.store.audit_get(self.store.audit_history(gone)[0]["id"]) self.assertEqual(row["action"], "delete") self.assertEqual(row["old"], "bye") self.app.approve = False self.assertIn("denied by user", self.tools.dispatch("delete_file", json.dumps({"path": kept}))) self.assertTrue(os.path.exists(kept)) def test_audit_tool_filters(self): first = self.workfile("f1.txt", "one") self.workfile("f2.txt", "two") self.tools.dispatch("read_file", json.dumps({"path": first})) self.tools.dispatch("edit_file", json.dumps({"path": first, "find": "one", "replace": "uno"})) by_path = self.tools.dispatch("audit", json.dumps({"path": first})) self.assertIn("edit", by_path) self.assertNotIn("f2.txt", by_path) self.assertIn("invalid limit", self.tools.dispatch("audit", json.dumps({"limit": "zzz"}))) self.assertIn("no audit rows match", self.tools.dispatch("audit", json.dumps({"path": "/nope/nothing"}))) def test_restore_post_pre_and_undelete(self): path = self.workfile("time.txt", "v1") self.tools.dispatch("read_file", json.dumps({"path": path})) self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "v1", "replace": "v2"})) rows = {row["action"]: row for row in self.store.audit_history(path)} self.assertIn("restored", self.tools.dispatch("restore", json.dumps({"id": rows["edit"]["id"]}))) with open(path, encoding="utf-8") as handle: self.assertEqual(handle.read(), "v2") self.tools.dispatch("shell", json.dumps({"command": "rm time.txt", "workdir": self.tmp.name})) snap = self.store.audit_history(path, tag="shell")[0] self.assertIn("restored", self.tools.dispatch("restore", json.dumps({"id": snap["id"]}))) with open(path, encoding="utf-8") as handle: self.assertEqual(handle.read(), "v2") history = self.store.audit_history(path) self.assertEqual(history[0]["action"], "restore") self.assertIn("restored from audit", history[0]["message"]) def test_restore_refuses_truncated(self): path = os.path.join(self.tmp.name, "huge.txt") self.tools.dispatch("write_file", json.dumps({"path": path, "content": "y" * 60000})) row_id = self.store.audit_history(path)[0]["id"] result = self.tools.dispatch("restore", json.dumps({"id": row_id})) self.assertIn("truncated", result) self.assertIn("cannot restore safely", result) def test_restore_repl_command(self): path = self.workfile("repl.txt", "keep") self.tools.dispatch("read_file", json.dumps({"path": path})) self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "keep", "replace": "changed"})) row_id = self.store.audit_history(path)[0]["id"] self.app.tools = self.tools with mock.patch("builtins.print") as shown: tai.handle_command(self.app, "/audit " + path) tai.handle_command(self.app, "/restore %d" % row_id) tai.handle_command(self.app, "/restore nope") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("edit", printed) self.assertIn("restored", printed) self.assertIn("use /restore ", printed) def test_lazy_blueprint_builds_on_load(self): with mock.patch.object(self.tools, "run_create_skill", return_value="created") as creator: result = self.tools.dispatch("load_skill", json.dumps({"name": "bot-creator"})) self.assertIn("building skill 'bot-creator' from blueprint", result) creator.assert_called_once() sent = creator.call_args.args[0] self.assertEqual(sent["name"], "bot-creator") self.assertIn("Deep-research", sent["brief"]) self.assertIn("unknown skill", self.tools.dispatch("load_skill", json.dumps({"name": "nope"}))) class ReleaseTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "release-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def script(self, version="1.2.3"): path = os.path.join(self.tmp.name, "tai-copy.py") with open(path, "w", encoding="utf-8") as handle: handle.write("#!/usr/bin/env python3\nVERSION = \"%s\"\nprint('hi')\n" % version) return path def test_bump_parts(self): self.assertEqual(tai.release_bump(self.script(), "patch"), ("1.2.3", "1.2.4")) self.assertEqual(tai.release_bump(self.script(), "minor"), ("1.2.3", "1.3.0")) self.assertEqual(tai.release_bump(self.script(), "major"), ("1.2.3", "2.0.0")) with open(self.script(), encoding="utf-8") as handle: self.assertIn('VERSION = "1.2.3"', handle.read()) with self.assertRaises(ValueError): tai.release_bump(self.script(), "banana") flat = os.path.join(self.tmp.name, "flat.py") with open(flat, "w", encoding="utf-8") as handle: handle.write("no version here\n") with self.assertRaises(ValueError): tai.release_bump(flat, "patch") def test_do_release_end_to_end(self): path = self.script() folder = tai.backups_dir(self.tmp.name) old, new, dest, row_id = tai.do_release(self.store, path, folder, "minor", "add audit trail") self.assertEqual((old, new), ("1.2.3", "1.3.0")) self.assertTrue(dest.startswith(folder)) self.assertIn("tai-1.3.0-", os.path.basename(dest)) with open(path, encoding="utf-8") as handle: text = handle.read() self.assertIn('VERSION = "1.3.0"', text) self.assertIn("print('hi')", text) row = self.store.audit_get(row_id) self.assertEqual(row["action"], "release") self.assertEqual(row["message"], "add audit trail") self.assertEqual(row["old"], "1.2.3") self.assertEqual(row["new"], "1.3.0") self.assertIn("release", row["tags"]) with self.assertRaises(ValueError): tai.do_release(self.store, path, folder, "patch", " ") def test_backup_prune_and_skip(self): folder = tai.backups_dir(self.tmp.name) os.makedirs(folder) now = time.time() for pos in range(12): name = os.path.join(folder, "tai-1.0.0-20200101T00000%dZ-abc%d.py" % (pos, pos)) with open(name, "w", encoding="utf-8") as handle: handle.write("old") stamp = now - (12 - pos) * 60 os.utime(name, (stamp, stamp)) path = self.script() dest, _digest = tai.snapshot_self(path, folder, "1.2.3") kept = [entry for entry in os.listdir(folder) if entry.endswith(".py")] self.assertEqual(len(kept), tai.BACKUP_KEEP) self.assertIn(os.path.basename(dest), kept) with open(path, "w", encoding="utf-8") as handle: handle.write("#!/usr/bin/env python3\nVERSION = \"9.9.9\"\nprint('changed')\n") first = tai.ensure_self_backup(self.config, self.store, script_file=path) self.assertIsNotNone(first) again = tai.ensure_self_backup(self.config, self.store, script_file=path) self.assertIsNone(again) snaps = self.store.audit_history(tag="snapshot") self.assertEqual(len(snaps), 1) self.assertIn("backed up as", snaps[0]["message"]) def test_release_tool_validates_safely(self): app = FakeSecretApp(self.store, approve=False) tools = tai.Tools(app) self.assertIn("part must be", tools.dispatch("release", json.dumps({"part": "banana", "message": "x"}))) self.assertIn("message is required", tools.dispatch("release", json.dumps({"part": "patch", "message": ""}))) denied = tools.dispatch("release", json.dumps({"part": "patch", "message": "try bump"})) self.assertIn("denied by user", denied) self.assertEqual(len(app.approvals), 1) self.assertIn("release %s (patch)" % tai.next_version(tai.VERSION, "patch"), app.approvals[0]) def test_boot_creates_self_backup(self): import argparse as ap args = ap.Namespace(profile="t", yes=True, yolo=False, auto=False) with mock.patch.dict(os.environ, {"TAI_PASSPHRASE": "release-test-1"}): with mock.patch("builtins.print"): agent = tai.boot(args) try: names = os.listdir(tai.backups_dir(self.tmp.name)) self.assertEqual(len(names), 1) self.assertTrue(names[0].startswith("tai-%s-" % tai.VERSION)) snaps = agent.store.audit_history(tag="snapshot") self.assertEqual(len(snaps), 1) finally: agent.store.close() class TaggingTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "tag-test-1")) self.app = FakeSecretApp(self.store) self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def mem_id(self, text): return re.search(r"mem:[0-9a-f]{16}", text).group(0) def test_singular_units(self): self.assertEqual(tai.singular_noun("servers"), "server") self.assertEqual(tai.singular_noun("cities"), "city") self.assertEqual(tai.singular_noun("boxes"), "box") self.assertEqual(tai.singular_noun("branches"), "branch") self.assertEqual(tai.singular_noun("glass"), "glass") self.assertEqual(tai.singular_noun("status"), "status") self.assertEqual(tai.singular_noun("news"), "news") self.assertEqual(tai.singular_noun("physics"), "physics") self.assertEqual(tai.singular_noun("api"), "api") def test_plural_merges_into_known_singular(self): first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "one", "content": "about a server", "tags": ["server"]}))) self.assertIn("server", self.store.item_tags(first)) second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "two", "content": "more", "tags": ["Servers"]}))) tags = self.store.item_tags(second) self.assertIn("server", tags) self.assertNotIn("servers", tags) self.assertNotIn("servers", self.store.known_tags()) def test_irregular_words_never_corrupt(self): record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "words", "content": "plain", "tags": ["news", "glass", "status", "physics"]}))) tags = self.store.item_tags(record_id) for word in ("news", "glass", "status", "physics"): self.assertIn(word, tags) def test_known_words_attach_automatically(self): self.tools.dispatch("record_save", json.dumps({"title": "seed", "content": "nothing yet", "tags": ["deploy", "server"]})) record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "friday", "content": "we deploy the thing friday", "tags": []}))) self.assertIn("deploy", self.store.item_tags(record_id)) self.assertNotIn("friday", self.store.item_tags(record_id)) plural = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "fleet", "content": "all servers rebooted", "tags": []}))) self.assertIn("server", self.store.item_tags(plural)) def test_new_records_link_to_same_tag_neighbors(self): first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "deploy alpha", "tags": ["deploy"]}))) saved = self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "deploy beta", "tags": ["deploy"]})) second = self.mem_id(saved) self.assertIn(first, saved) edges = self.store.edges_for(second) self.assertEqual(len(edges), 1) self.assertEqual(edges[0]["relation"], "shares-deploy") self.assertEqual(edges[0]["other"], first) def test_baseline_only_records_do_not_link(self): self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "lorem ipsum", "tags": []})) second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "dolor sit", "tags": []}))) self.assertEqual(self.store.edges_for(second), []) def test_auto_link_caps_at_three(self): for pos in range(5): self.tools.dispatch("record_save", json.dumps({"title": "n%d" % pos, "content": "shared topic here", "tags": ["topic"]})) sixth = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "n5", "content": "shared topic again", "tags": ["topic"]}))) edges = [edge for edge in self.store.edges_for(sixth) if edge["direction"] == "out"] self.assertEqual(len(edges), 3) def test_search_expands_singular_plural(self): legacy = self.store.add_record("note", "old", "zeta-content", ["servers"]) self.assertIn("servers", self.store.item_tags(legacy)) modern = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "new", "content": "zeta-content", "tags": ["server"]}))) found = self.tools.dispatch("record_search", json.dumps({"tags": ["server"]})) self.assertIn(legacy, found) self.assertIn(modern, found) def test_tags_tool_counts_and_prefix(self): self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "x", "tags": ["deploy", "friday"]})) self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "y", "tags": ["deploy"]})) listed = self.tools.dispatch("tags", "{}") self.assertIn("deploy (2)", listed) self.assertIn("friday (1)", listed) self.assertLess(listed.index("deploy (2)"), listed.index("friday (1)")) prefixed = self.tools.dispatch("tags", json.dumps({"prefix": "fri"})) self.assertIn("friday (1)", prefixed) self.assertNotIn("deploy", prefixed) self.app.tools = self.tools with mock.patch("builtins.print") as shown: tai.handle_command(self.app, "/tags") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("deploy (2)", printed) class ProfileTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "profile-test-1")) self.app = FakeSecretApp(self.store) self.tools = tai.Tools(self.app) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def mem_id(self, text): return re.search(r"mem:[0-9a-f]{16}", text).group(0) def test_records_isolated(self): record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "mine", "content": "t-only words", "tags": ["t-tag"]}))) self.assertIsNone(self.store.get_record(record_id, "other")) self.assertEqual(self.store.search_records("t-only", profile="other"), []) self.assertEqual(len(self.store.search_records("t-only", profile="t")), 1) self.app.profile = "other" self.assertIn("no records match", self.tools.dispatch("record_search", json.dumps({"query": "t-only"}))) self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": record_id}))) def test_secrets_isolated(self): self.tools.dispatch("store_secret", json.dumps({"name": "api", "value": "t-value-123"})) self.assertIsNone(self.store.load_secret("api", "other")) self.assertEqual(self.store.list_secret_infos("other"), []) self.store.save_secret("api", "other-value-456", None, None, "other") self.assertEqual(self.store.load_secret("api", "t"), "t-value-123") self.assertEqual(self.store.load_secret("api", "other"), "other-value-456") self.assertIn("[redacted:api]", self.store.redact("leak t-value-123 here", "t")) self.assertIn("t-value-123", self.store.redact("leak t-value-123 here", "other")) self.app.profile = "other" listed = self.tools.dispatch("list_secrets", "{}") self.assertIn("api", listed) self.assertNotIn("t-value-123", listed) def test_tags_vocab_isolated(self): self.tools.dispatch("record_save", json.dumps({"title": "seed", "content": "nothing", "tags": ["deploy"]})) self.app.profile = "other" record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "fresh", "content": "we deploy friday", "tags": []}))) self.assertNotIn("deploy", self.store.item_tags(record_id, "other")) self.assertEqual(self.store.tag_counts(profile="other"), [("record", 1)]) self.assertIn("deploy", self.store.known_tags("t")) def test_edges_isolated(self): first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "x", "tags": ["linkable"]}))) second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "y", "tags": ["unlinked"]}))) self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": second, "relation": "uses"})) self.app.profile = "other" self.assertIn("unknown node", self.tools.dispatch("graph_query", json.dumps({"node": first}))) self.assertEqual(self.store.edges_for(first, "other"), []) self.app.profile = "t" view = self.tools.dispatch("graph_query", json.dumps({"node": first})) self.assertIn("uses", view) def test_audit_isolated(self): path = os.path.join(self.tmp.name, "aud.txt") self.tools.dispatch("write_file", json.dumps({"path": path, "content": "t-data"})) self.assertEqual(len(self.store.audit_history(profile="t")), 1) self.assertEqual(self.store.audit_history(profile="other"), []) row_id = self.store.audit_history(profile="t")[0]["id"] self.assertIsNone(self.store.audit_get(row_id, "other")) self.app.profile = "other" self.assertIn("no audit rows match", self.tools.dispatch("audit", "{}")) self.assertIn("unknown audit", self.tools.dispatch("restore", json.dumps({"id": row_id}))) def test_schedules_isolated(self): app = FakeSchedulerApp(self.store) tools = tai.Tools(app) tools.dispatch("schedule", json.dumps({"prompt": "t-job", "every": 3600})) self.assertEqual(len(self.store.list_schedules("t")), 1) self.assertEqual(self.store.list_schedules("other"), []) row_id = self.store.list_schedules("t")[0]["id"] app.profile = "other" self.assertIn("no schedule", tools.dispatch("unschedule", json.dumps({"id": row_id}))) self.assertFalse(self.store.remove_schedule(row_id, "other")) self.assertTrue(self.store.remove_schedule(row_id, "t")) self.assertIn("another profile", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 60, "profile": "t"}))) def test_fork_and_poll_scoped(self): first = tai.spawn_agent("task-t", "t", 60, None, None, 0, lambda task, profile, timeout: "done-t") second = tai.spawn_agent("task-other", "other", 60, None, None, 0, lambda task, profile, timeout: "done-other") self.assertEqual(tai.poll_agent(first, wait=5, profile="t")[0], "done") self.assertEqual(tai.poll_agent(second, profile="t")[0], "missing") self.assertEqual([item["id"] for item in tai.list_agents("t")], [first]) app = FakePrincipal() app.store = self.store tools = tai.Tools(app) self.assertIn("done-t", tools.dispatch("poll", json.dumps({"id": first}))) self.assertIn("missing", tools.dispatch("poll", json.dumps({"id": second}))) self.assertIn("another profile", tools.dispatch("fork", json.dumps({"task": "x", "profile": "other"}))) def test_switch_resets_identity(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) agent.tools.read_files.add(("home", "/tmp/x")) agent.tools.secret_grants.add(("shell", "", ("api",))) with mock.patch("builtins.print"): agent.switch_profile("newbie") self.assertEqual(agent.profile, "newbie") self.assertEqual(self.store.profile, "newbie") self.assertEqual(agent.tools.read_files, set()) self.assertEqual(agent.tools.secret_grants, set()) self.assertEqual([item["role"] for item in agent.messages], ["system"]) self.assertIn("newbie", self.store.list_profiles()) self.assertIn("t", self.store.list_profiles()) def test_recall_scoped(self): self.store.log_event("t", "user", "message", "t recall marker") self.store.log_event("other", "user", "message", "other recall marker") found = self.tools.dispatch("recall", json.dumps({"query": "recall marker"})) self.assertIn("t recall marker", found) self.assertNotIn("other recall marker", found) def test_profile_list_is_global(self): self.store.save_system("alpha", "system a") self.store.save_system("beta", "system b") self.assertIn("alpha", self.store.list_profiles()) self.assertIn("beta", self.store.list_profiles()) class MigrationTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "default" yes = True self.config = tai.Config(FakeArgs()) def tearDown(self): self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_legacy_db_migrates_to_default(self): db = sqlite3.connect(self.config.db_path) db.execute("CREATE TABLE secrets (name TEXT PRIMARY KEY, value TEXT, meta TEXT, updated TEXT)") db.execute("INSERT INTO secrets VALUES ('k', 'v-legacy', '{}', '2026-01-01')") db.execute("CREATE TABLE records (id TEXT PRIMARY KEY, kind TEXT, title TEXT, content TEXT, size INTEGER, reads INTEGER, created TEXT, updated TEXT)") db.execute("INSERT INTO records VALUES ('mem:0123456789abcdef', 'note', 'old', 'old words', 9, 0, '', '')") db.execute("CREATE TABLE tags (item TEXT, tag TEXT, PRIMARY KEY (item, tag))") db.execute("INSERT INTO tags VALUES ('mem:0123456789abcdef', 'legacy')") db.execute("CREATE TABLE edges (src TEXT, dst TEXT, relation TEXT, created TEXT, PRIMARY KEY (src, dst, relation))") db.execute("CREATE TABLE audit (id INTEGER PRIMARY KEY, ts TEXT, actor TEXT, action TEXT, path TEXT, message TEXT, old_size INTEGER, new_size INTEGER, old TEXT, new TEXT, tags TEXT)") db.execute("INSERT INTO audit VALUES (1, 't', 'main', 'write', '/x', '', 0, 9, NULL, 'old words', '')") db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, role TEXT, kind TEXT, text TEXT, tags TEXT)") db.execute("CREATE TABLE schedules (id INTEGER PRIMARY KEY, name TEXT, prompt TEXT, profile TEXT, every_sec INTEGER, next_run TEXT, timeout INTEGER, status TEXT, last_status TEXT, last_result TEXT, created TEXT, updated TEXT)") db.commit() db.close() store = tai.Store(self.config, tai.Seal(self.config.home, "")) try: self.assertEqual(store.load_secret("k"), "v-legacy") store.save_secret("k", "v-work", None, None, "work") self.assertEqual(store.load_secret("k", "work"), "v-work") self.assertEqual(store.load_secret("k"), "v-legacy") self.assertEqual(store.get_record("mem:0123456789abcdef")["content"], "old words") self.assertIn("legacy", store.item_tags("mem:0123456789abcdef")) self.assertEqual(len(store.audit_history()), 1) self.assertEqual(tai.table_pk_columns(store.db, "secrets"), ["profile", "name"]) self.assertEqual(tai.table_pk_columns(store.db, "tags"), ["item", "tag", "profile"]) finally: store.close() def test_sparse_legacy_db_auto_heals(self): db = sqlite3.connect(self.config.db_path) db.execute("CREATE TABLE secrets (name TEXT PRIMARY KEY, value TEXT, updated TEXT)") db.execute("INSERT INTO secrets VALUES ('srv', 'pw', 't')") db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, role TEXT, kind TEXT, text TEXT)") db.execute("INSERT INTO events VALUES (1, 'default', 't', 'user', 'message', 'hello world')") db.execute("CREATE TABLE records (id TEXT PRIMARY KEY, kind TEXT, title TEXT, content TEXT, size TEXT, reads INTEGER, created TEXT, updated TEXT)") db.execute("INSERT INTO records VALUES ('mem:aaaaaaaaaaaaaaaa', 'note', 't', 'words', '5', 0, '', '')") db.commit() db.close() store = tai.Store(self.config, tai.Seal(self.config.home, "")) try: self.assertEqual(store.load_secret("srv"), "pw") self.assertEqual(store.get_record("mem:aaaaaaaaaaaaaaaa")["content"], "words") self.assertEqual(store.get_record("mem:aaaaaaaaaaaaaaaa")["size"], 5) self.assertEqual(len(store.search_events("default", "hello")), 1) for table in ("secrets", "events", "records", "tags", "edges", "audit", "schedules"): self.assertTrue(store.db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,)).fetchone(), table) secret_cols = [row[1] for row in store.db.execute("PRAGMA table_info(secrets)").fetchall()] event_cols = [row[1] for row in store.db.execute("PRAGMA table_info(events)").fetchall()] record_cols = [row[1] for row in store.db.execute("PRAGMA table_info(records)").fetchall()] self.assertIn("meta", secret_cols) self.assertIn("tags", event_cols) self.assertIn("profile", record_cols) self.assertIn("added secrets.meta", store.schema_notes) self.assertIn("added events.tags", store.schema_notes) self.assertIn("added records.profile", store.schema_notes) finally: store.close() def test_legacy_db_without_meta_migrates(self): db = sqlite3.connect(self.config.db_path) db.execute("CREATE TABLE secrets (name TEXT PRIMARY KEY, value TEXT, updated TEXT)") db.execute("INSERT INTO secrets VALUES ('srv', 'pw-legacy', '2026-01-01')") db.commit() db.close() store = tai.Store(self.config, tai.Seal(self.config.home, "")) try: self.assertEqual(store.load_secret("srv"), "pw-legacy") self.assertEqual(store.secret_meta("srv"), {}) self.assertEqual(tai.table_pk_columns(store.db, "secrets"), ["profile", "name"]) names = [row[1] for row in store.db.execute("PRAGMA table_info(secrets)").fetchall()] self.assertIn("meta", names) store.save_secret("srv", "pw-new", {"host": "example.com"}, None, "default") self.assertEqual(store.secret_meta("srv"), {"host": "example.com"}) finally: store.close() class LazyTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "lazy-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def names(self, text): return {schema["function"]["name"] for schema in tai.select_tools(text)} def test_core_minimal(self): self.assertEqual(self.names("hello"), set(tai.CORE_TOOLS)) self.assertEqual(len(tai.CORE_TOOLS), 9) def test_synonym_triggers(self): pairs = [ ("remove that file", "delete_file"), ("set a cron reminder", "schedule"), ("my password", "store_secret"), ("undo that change", "restore"), ("show version history", "audit"), ("what tags exist", "tags"), ("publish a release", "release"), ("search the web", "web_search"), ("transcribe this", "listen"), ("check the host specs", "sysinfo"), ("upcoming agenda", "schedules"), ("connect these nodes", "graph_link"), ] for text, tool in pairs: self.assertIn(tool, self.names(text), "missing %s for %r" % (tool, text)) def test_name_mention_loads_family(self): found = self.names("use record_save for this") for tool in ("record_save", "record_read", "record_search", "record_delete"): self.assertIn(tool, found) def test_no_overtrigger(self): self.assertEqual(self.names("hello, how are you today"), set(tai.CORE_TOOLS)) self.assertNotIn("edit_file", self.names("tell me about this")) def test_catalog_lists_lazy(self): catalog = tai.tool_catalog() self.assertIn("## Tool catalog", catalog) for schema in tai.TOOL_SCHEMAS: name = schema["function"]["name"] if name in tai.CORE_TOOLS: continue if not tai.tool_env_ok(name): continue self.assertIn("- %s:" % name, catalog) def test_tool_results_feed_selection(self): messages = [ {"role": "user", "content": "run it"}, {"role": "assistant", "content": None, "tool_calls": []}, {"role": "tool", "content": "exit 0\n...[7000 chars spilled to mem:0123456789abcdef, record_read pages the rest]..."}, ] text = tai.conversation_text(messages) self.assertIn("record_read", text) self.assertIn("record_read", self.names(text)) def test_run_turn_uses_filtered_payload(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=True) seen = {} reply = {"role": "assistant", "content": "hi", "reasoning": "", "tool_calls": [], "backend": "x"} def fake_complete(messages, tools, stream_sink=None, deadline=None): seen["tools"] = {schema["function"]["name"] for schema in tools} return dict(reply) with mock.patch.object(agent.chat, "complete", side_effect=fake_complete): agent.run_turn("hi", capture=True) self.assertEqual(seen["tools"], set(tai.CORE_TOOLS)) def test_tools_repl(self): agent = mock.Mock() with mock.patch("builtins.print") as shown: tai.handle_command(agent, "/tools") tai.handle_command(agent, "/tools schedule") tai.handle_command(agent, "/tools nope") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("core, always loaded", printed) self.assertIn("schedule", printed) self.assertIn("cron", printed) self.assertIn("unknown tool", printed) class InstallOpsTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.home = os.path.join(self.tmp.name, "home") os.makedirs(self.home) self.env = mock.patch.dict(os.environ, {"HOME": self.home, "TAI_HOME": os.path.join(self.home, ".tai")}) self.env.start() class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "install-test-1")) self.app = FakeSecretApp(self.store) self.app.config = self.config self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.env.stop() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) tai._BOX_PYTHON_CACHE.clear() def test_status_reports_all_targets(self): with mock.patch.object(tai.Sandbox, "container_engine", return_value=None): report = tai.install_report("status", list(tai.INSTALL_TARGETS), self.config.home, self.store) for label in ( "binary:", "bash-hook:", "zsh-hook:", "fish-hook:", "plexer-hook:", "zsh-plexer-hook:", "fish-plexer-hook:", "venv:", "scheduler-service:", "telegram-service:", "container:", "vault:", ): self.assertIn(label, report) self.assertIn("not installed", report) self.assertIn("never touch data", report) def test_binary_install_upgrade_uninstall(self): target = os.path.join(self.home, ".local", "bin", "tai.py") self.assertIn("installed", tai.op_binary("install", self.config.home)) self.assertTrue(os.access(target, os.X_OK)) with open(os.path.abspath(tai.__file__), "rb") as handle: self.assertEqual(open(target, "rb").read(), handle.read()) self.assertIn("already installed", tai.op_binary("install", self.config.home)) self.assertIn("refreshed", tai.op_binary("upgrade", self.config.home)) self.assertIn("removed", tai.op_binary("uninstall", self.config.home)) self.assertFalse(os.path.exists(target)) self.assertIn("not present", tai.op_binary("uninstall", self.config.home)) def test_hook_install_remove_idempotent(self): path = os.path.join(self.home, ".bashrc") self.assertIn("source ~/.bashrc", tai.op_hook("install", self.config.home)) with open(path, encoding="utf-8") as handle: self.assertIn(tai.BASHRC_MARK_BEGIN, handle.read()) self.assertIn("already present", tai.op_hook("install", self.config.home)) self.assertIn("removed", tai.op_hook("uninstall", self.config.home)) with open(path, encoding="utf-8") as handle: self.assertNotIn(tai.BASHRC_MARK_BEGIN, handle.read()) self.assertTrue(os.path.isfile(path + ".bak-tai")) self.assertIn("not present", tai.op_hook("uninstall", self.config.home)) def test_zsh_hook_install_remove_idempotent(self): path = os.path.join(self.home, ".zshrc") with mock.patch.object(tai.Install, "shell_present", return_value=True): self.assertIn("source ~/.zshrc", tai.op_zsh_hook("install", self.config.home)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn(tai.BASHRC_MARK_BEGIN, content) self.assertIn("command_not_found_handler", content) self.assertNotIn("command_not_found_handle()", content) self.assertIn("already present", tai.op_zsh_hook("install", self.config.home)) self.assertIn("removed", tai.op_zsh_hook("uninstall", self.config.home)) with open(path, encoding="utf-8") as handle: self.assertNotIn(tai.BASHRC_MARK_BEGIN, handle.read()) self.assertTrue(os.path.isfile(path + ".bak-tai")) self.assertIn("not present", tai.op_zsh_hook("uninstall", self.config.home)) def test_fish_hook_install_remove_idempotent(self): path = os.path.join(self.home, ".config", "fish", "config.fish") with mock.patch.object(tai.Install, "shell_present", return_value=True): result = tai.op_fish_hook("install", self.config.home) self.assertIn("installed", result) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn(tai.BASHRC_MARK_BEGIN, content) self.assertIn("function fish_command_not_found", content) self.assertIn("already present", tai.op_fish_hook("install", self.config.home)) self.assertIn("removed", tai.op_fish_hook("uninstall", self.config.home)) with open(path, encoding="utf-8") as handle: self.assertNotIn(tai.BASHRC_MARK_BEGIN, handle.read()) self.assertTrue(os.path.isfile(path + ".bak-tai")) def test_shell_hook_skips_loudly_when_shell_missing(self): with mock.patch.object(tai.Install, "shell_present", return_value=False): self.assertEqual(tai.op_zsh_hook("install", self.config.home), "zsh-hook: zsh not found, skipped") self.assertEqual(tai.op_fish_hook("upgrade", self.config.home), "fish-hook: fish not found, skipped") self.assertEqual( tai.op_zsh_plexer_hook("install", self.config.home), "zsh-plexer-hook: zsh not found, skipped", ) self.assertEqual( tai.op_fish_plexer_hook("install", self.config.home), "fish-plexer-hook: fish not found, skipped", ) self.assertFalse(os.path.exists(os.path.join(self.home, ".zshrc"))) self.assertFalse(os.path.exists(os.path.join(self.home, ".config", "fish", "config.fish"))) def test_shell_hook_uninstall_cleans_up_even_if_shell_missing_now(self): with mock.patch.object(tai.Install, "shell_present", return_value=True): tai.op_zsh_hook("install", self.config.home) with mock.patch.object(tai.Install, "shell_present", return_value=False): self.assertIn("removed", tai.op_zsh_hook("uninstall", self.config.home)) def test_zsh_plexer_hook_reuses_bash_syntax_different_file(self): bash_path = os.path.join(self.home, ".bashrc") zsh_path = os.path.join(self.home, ".zshrc") with mock.patch.object(tai.Install, "shell_present", return_value=True): tai.op_plexer_hook("install", self.config.home) tai.op_zsh_plexer_hook("install", self.config.home) with open(bash_path, encoding="utf-8") as handle: bash_content = handle.read() with open(zsh_path, encoding="utf-8") as handle: zsh_content = handle.read() self.assertEqual(bash_content, zsh_content) self.assertIn('case "$-" in', zsh_content) def test_fish_plexer_hook_uses_fish_syntax(self): path = os.path.join(self.home, ".config", "fish", "config.fish") with mock.patch.object(tai.Install, "shell_present", return_value=True): self.assertIn("installed", tai.op_fish_plexer_hook("install", self.config.home)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn("status is-interactive", content) self.assertIn("isatty stdin", content) self.assertIn("isatty stdout", content) self.assertIn("exec \"$HOME/.local/bin/tai.py\" --plexer-wrap", content) self.assertNotIn("case \"$-\"", content) self.assertNotIn("[ -z \"$TAI_PLEXER_ACTIVE\" ]", content) def test_install_self_detects_current_shell(self): with mock.patch.object(tai.Install, "shell_present", return_value=True): with mock.patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}): with mock.patch("builtins.print") as shown: tai.install_self() printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("zsh-hook:", printed) self.assertIn("source ~/.zshrc", printed) def test_venv_kept_and_removed(self): folder = tai.venv_dir(self.config.home) python = os.path.join(folder, "bin", "python") os.makedirs(os.path.dirname(python)) with open(python, "w", encoding="utf-8") as handle: handle.write("#!/bin/sh\n") os.chmod(python, 0o755) self.assertIn("kept", tai.op_venv("install", self.config.home)) self.assertIn(python, tai.service_exec(self.config.home, "/x/tai.py")) self.assertIn("removed", tai.op_venv("uninstall", self.config.home)) self.assertFalse(os.path.isdir(folder)) self.assertEqual(tai.service_exec(self.config.home, "/x/tai.py"), "/x/tai.py") def test_venv_create_and_unavailable(self): with mock.patch.object(tai.Install, "venv_available", return_value=False): self.assertIn("unavailable", tai.op_venv("install", self.config.home)) def fake_run(argv, stdout=None, stderr=None, universal_newlines=True, timeout=300): folder = argv[-1] python = os.path.join(folder, "bin", "python") os.makedirs(os.path.dirname(python)) with open(python, "w", encoding="utf-8") as handle: handle.write("#!/bin/sh\n") os.chmod(python, 0o755) outcome = mock.Mock() outcome.returncode = 0 outcome.stderr = "" return outcome with mock.patch.object(tai.Install, "venv_available", return_value=True): with mock.patch("subprocess.run", side_effect=fake_run): self.assertIn("created", tai.op_venv("install", self.config.home)) def test_scheduler_unit_write_and_remove(self): with mock.patch("shutil.which", return_value=None): self.assertIn("unit written", tai.op_scheduler_service("install", self.config.home)) unit = os.path.join(self.home, ".config", "systemd", "user", "tai-scheduler.service") with open(unit, encoding="utf-8") as handle: body = handle.read() self.assertIn("--scheduler", body) self.assertIn("already installed", tai.op_scheduler_service("install", self.config.home)) self.assertIn("removed", tai.op_scheduler_service("uninstall", self.config.home)) self.assertFalse(os.path.exists(unit)) def test_telegram_needs_token(self): with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("TELEGRAM_BOT_TOKEN", None) self.assertIn("TELEGRAM_BOT_TOKEN", tai.op_telegram_service("install", self.config.home)) self.assertIn("not present", tai.op_telegram_service("uninstall", self.config.home)) def test_reinstall_keeps_vault(self): marker = b"vault-data-marker-7" with open(os.path.join(self.config.home, "memory.db"), "wb") as handle: handle.write(marker) with mock.patch.object(tai.Install, "ensure_venv", return_value=(True, "created fake")): report = tai.install_report("reinstall", ["binary", "bash-hook", "venv"], self.config.home, self.store) with open(os.path.join(self.config.home, "memory.db"), "rb") as handle: self.assertIn(marker, handle.read()) self.assertIn("never touch data", report) def test_install_tool_validation_and_approval(self): self.assertIn("action must be", self.tools.dispatch("install", json.dumps({"action": "explode"}))) self.assertIn("unknown target", self.tools.dispatch("install", json.dumps({"action": "status", "targets": ["nope"]}))) with mock.patch.object(tai.Sandbox, "container_engine", return_value=None): status = self.tools.dispatch("install", json.dumps({"action": "status"})) self.assertIn("scheduler-service:", status) self.app.approve = False self.assertIn("denied by user", self.tools.dispatch("install", json.dumps({"action": "upgrade", "targets": ["venv"]}))) self.assertIn("vault always kept", self.app.approvals[0]) self.app.approve = True done = self.tools.dispatch("install", json.dumps({"action": "install", "targets": ["bash-hook"]})) self.assertIn("bash-hook:", done) def test_install_mentions_load_tool(self): found = {schema["function"]["name"] for schema in tai.select_tools("how do I install tai as a service")} self.assertIn("install", found) found = {schema["function"]["name"] for schema in tai.select_tools("set up a venv hook")} self.assertIn("install", found) def test_box_python_cache_and_containerfile(self): self.assertIn("/box/venv", tai.BOX_CONTAINERFILE) self.assertIn("python3-venv", tai.BOX_CONTAINERFILE) probe = mock.Mock() probe.returncode = 0 with mock.patch.object(tai.Sandbox, "box_exec", return_value=probe) as runner: self.assertEqual(tai.box_python("podman"), tai.BOX_PYTHON) self.assertEqual(tai.box_python("podman"), tai.BOX_PYTHON) self.assertEqual(runner.call_count, 1) tai._BOX_PYTHON_CACHE.clear() probe.returncode = 1 with mock.patch.object(tai.Sandbox, "box_exec", return_value=probe): self.assertEqual(tai.box_python("podman"), "python3") def test_unit_templates_format(self): body = tai.TELEGRAM_UNIT % ("/x/tai.py", "/x/telegram.env") self.assertIn("WorkingDirectory=%h", body) self.assertIn("/x/tai.py --telegram", body) body = tai.SCHEDULER_UNIT % "/x/tai.py" self.assertIn("/x/tai.py --scheduler", body) def test_install_repl(self): self.app.tools = self.tools with mock.patch.object(tai.Sandbox, "container_engine", return_value=None): with mock.patch("builtins.print") as shown: tai.handle_command(self.app, "/install status") tai.handle_command(self.app, "/install frobnicate") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("bash-hook:", printed) self.assertIn("use /install", printed) class BotsTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "bots-test-1")) self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) self.tools = self.agent.tools def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_create_bot_roundtrip(self): result = self.tools.dispatch("create_bot", json.dumps({"name": "Helper-Bot", "description": "helps a lot", "rules": "be kind", "behavior": "concise", "nicknames": ["help"]})) self.assertIn("created bot 'helper-bot'", result) self.assertIn("help", result) self.assertIn("helper", result) bots = {item["name"]: item for item in self.store.list_bots("t")} self.assertIn("helper-bot", bots) self.assertIn("help", bots["helper-bot"]["nicknames"]) self.assertIn("helper", bots["helper-bot"]["nicknames"]) system = self.store.load_bot_system("t", "helper-bot") self.assertIn("helps a lot", system) self.assertIn("Rules:", system) self.assertIn("Behavior:", system) self.assertEqual(self.store.resolve_bot("t", "helper-bot"), "helper-bot") self.assertEqual(self.store.resolve_bot("t", "HELP"), "helper-bot") self.assertEqual(self.store.resolve_bot("t", "main"), "main") self.assertIsNone(self.store.resolve_bot("t", "nope")) def test_create_bot_validation(self): self.assertIn("invalid bot name", self.tools.dispatch("create_bot", json.dumps({"name": "Bad Name!"}))) self.assertIn("default bot", self.tools.dispatch("create_bot", json.dumps({"name": "main", "description": "x"}))) self.assertIn("at least one", self.tools.dispatch("create_bot", json.dumps({"name": "empty"}))) self.assertIn("nicknames must be", self.tools.dispatch("create_bot", json.dumps({"name": "x", "description": "y", "nicknames": "nope"}))) self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "writes code", "nicknames": ["cd"]})) self.assertIn("taken", self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "again"}))) self.assertIn("taken", self.tools.dispatch("create_bot", json.dumps({"name": "other", "description": "y", "nicknames": ["cd"]}))) self.assertIn("invalid nickname", self.tools.dispatch("create_bot", json.dumps({"name": "ok", "description": "y", "nicknames": ["bad nick!"]}))) def test_switch_bot_resumes(self): self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "writes code", "nicknames": ["cd"]})) self.agent.messages.append({"role": "user", "content": "main hello"}) self.agent.messages.append({"role": "assistant", "content": "main hi"}) with mock.patch("builtins.print"): self.assertIn("switched", self.agent.switch_bot("coder")) self.assertEqual(self.agent.bot, "coder") self.assertEqual([item["role"] for item in self.agent.messages], ["system"]) self.assertIn("writes code", self.agent.system_message) self.agent.messages.append({"role": "user", "content": "coder hello"}) with mock.patch("builtins.print"): self.agent.switch_bot("main") contents = [item.get("content", "") for item in self.agent.messages] self.assertIn("main hello", contents) self.assertNotIn("coder hello", contents) with mock.patch("builtins.print"): self.assertIn("switched", self.agent.switch_bot("cd")) self.assertIn("coder hello", [item.get("content", "") for item in self.agent.messages]) def test_mention_routes_and_crossposts(self): self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "coder-brain-9"})) seen = [] reply = {"role": "assistant", "content": "coded-it", "reasoning": "", "tool_calls": [], "backend": "x"} def fake_complete(messages, tools, stream_sink=None, deadline=None): seen.append(messages[0]["content"]) return dict(reply) with mock.patch.object(self.agent.chat, "complete", side_effect=fake_complete): answer = self.agent.run_turn("@coder write frob", capture=True) self.assertEqual(answer, "coded-it") self.assertIn("coder-brain-9", seen[0]) self.assertEqual(self.agent.bot, "main") tail = self.agent.messages[-2:] self.assertEqual(tail[0]["content"], "@coder write frob") self.assertEqual(tail[1]["content"], "coded-it") restored = self.store.load_session("t", "coder") texts = [item.get("content", "") for item in restored] self.assertIn("write frob", texts) self.assertIn("coded-it", texts) def test_mention_unknown_and_self(self): reply = {"role": "assistant", "content": "hi", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(self.agent.chat, "complete", return_value=dict(reply)): self.assertIn("unknown bot", self.agent.run_turn("@ghost hi", capture=True)) self.assertEqual(self.agent.run_turn("@main hi", capture=True), "hi") self.assertEqual(self.agent.messages[-2]["content"], "hi") def test_remember_goes_to_current_bot(self): self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1"})) with mock.patch("builtins.print"): self.agent.switch_bot("coder") self.agent.update_system("coder rules v2") self.assertIn("v2", self.store.load_bot_system("t", "coder")) self.assertNotIn("v2", self.store.load_system("t")) def test_profile_switch_resets_bot(self): self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1"})) with mock.patch("builtins.print"): self.agent.switch_bot("coder") self.agent.switch_profile("other") self.assertEqual(self.agent.bot, "main") def test_bots_repl(self): self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1", "nicknames": ["cd"]})) with mock.patch("builtins.print") as shown: tai.handle_command(self.agent, "/bots") tai.handle_command(self.agent, "/bot coder") tai.handle_command(self.agent, "/bot") tai.handle_command(self.agent, "/bot nope") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("* main", printed) self.assertIn("coder aka cd", printed) self.assertIn("switched to bot", printed) self.assertIn("use /bot ", printed) self.assertIn("unknown bot", printed) self.assertEqual(self.agent.bot, "coder") class MentionTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name self.workdir = tempfile.TemporaryDirectory() self.saved_cwd = os.getcwd() os.chdir(self.workdir.name) class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "mention-test-1")) self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) def tearDown(self): os.chdir(self.saved_cwd) self.store.close() self.workdir.cleanup() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def fake_chat(self, seen): reply = {"role": "assistant", "content": "done-1", "reasoning": "", "tool_calls": [], "backend": "x"} def fake_complete(messages, tools, stream_sink=None, deadline=None): seen.append([item.get("content", "") for item in messages if item.get("role") == "user"]) seen.append(self.agent.chat.provider_pin) return dict(reply) return mock.patch.object(self.agent.chat, "complete", side_effect=fake_complete) def tools_dispatch_create_bot(self): self.agent.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "writes code"})) def test_split_leading_tags(self): self.assertEqual(tai.split_leading_tags("@a @b rest"), (["a", "b"], "rest")) self.assertEqual(tai.split_leading_tags("@a"), (["a"], "")) self.assertEqual(tai.split_leading_tags("hi @a"), ([], "hi @a")) self.assertEqual(tai.split_leading_tags("@"), ([], "@")) self.assertEqual(tai.split_leading_tags(""), ([], "")) def test_resolve_precedence(self): self.tools_dispatch_create_bot() with open("coder", "w") as handle: handle.write("x") with open("note.txt", "w") as handle: handle.write("x") self.assertEqual(tai.resolve_tag("coder", self.agent, os.getcwd()), ("bot", "coder")) self.assertEqual(tai.resolve_tag("opencode", self.agent, os.getcwd()), ("provider", "opencode")) self.assertEqual(tai.resolve_tag("mimo-v2.6-flash-free", self.agent, os.getcwd()), ("model", "mimo-v2.6-flash-free")) self.assertEqual(tai.resolve_tag("auto", self.agent, os.getcwd()), ("auto", "auto")) kind, value = tai.resolve_tag("note.txt", self.agent, os.getcwd()) self.assertEqual(kind, "file") self.assertTrue(value.endswith("note.txt")) self.assertEqual(tai.resolve_tag("nope-zzz", self.agent, os.getcwd()), ("unknown", "nope-zzz")) def test_combined_tags_route_and_pin(self): self.tools_dispatch_create_bot() seen = [] with self.fake_chat(seen): answer = self.agent.run_turn("@opencode @coder write frob", capture=True) self.assertEqual(answer, "done-1") self.assertEqual(self.agent.chat.provider_pin, "opencode") self.assertEqual(self.agent.bot, "main") self.assertEqual(seen[1], "opencode") saved = self.store.load_dir_defaults(tai.dir_key()) self.assertEqual(saved, {"provider": "opencode", "bot": "coder"}) def test_last_tag_wins(self): answer = self.agent.run_turn("@ollama @opencode", capture=True) self.assertIn("opencode", answer) self.assertEqual(self.agent.chat.provider_pin, "opencode") def test_unknown_tag_errors(self): answer = self.agent.run_turn("@ghost-zzz hi", capture=True) self.assertIn("unknown bot", answer) self.assertIn("@ghost-zzz", answer) self.assertIsNone(self.agent.chat.provider_pin) self.assertEqual(self.store.load_dir_defaults(tai.dir_key()), {}) def test_bare_selectors_confirm_and_save(self): seen = [] with self.fake_chat(seen): answer = self.agent.run_turn("@opencode", capture=True) self.assertIn("provider pinned to opencode", answer) self.assertIn("saved as default", answer) self.assertEqual(seen, []) self.assertEqual(self.store.load_dir_defaults(tai.dir_key()), {"provider": "opencode"}) def test_bare_bot_switches_sticky(self): self.tools_dispatch_create_bot() answer = self.agent.run_turn("@coder", capture=True) self.assertIn("switched to bot", answer) self.assertEqual(self.agent.bot, "coder") self.assertEqual(self.store.load_dir_defaults(tai.dir_key()), {"bot": "coder"}) def test_auto_clears(self): self.agent.chat.provider_pin = "opencode" self.agent.chat.model_pin = "mimo-v2.6-flash-free" self.store.save_dir_defaults(tai.dir_key(), provider="opencode", model="mimo-v2.6-flash-free", bot="main") answer = self.agent.run_turn("@auto", capture=True) self.assertIn("pins cleared", answer) self.assertIsNone(self.agent.chat.provider_pin) self.assertIsNone(self.agent.chat.model_pin) self.assertEqual(self.store.load_dir_defaults(tai.dir_key()), {"bot": "main"}) def test_model_tag_pins(self): seen = [] with self.fake_chat(seen): self.agent.run_turn("@mimo-v2.6-flash-free hi", capture=True) self.assertEqual(self.agent.chat.model_pin, "mimo-v2.6-flash-free") self.assertEqual(self.store.load_dir_defaults(tai.dir_key()), {"model": "mimo-v2.6-flash-free"}) def test_midtext_file_expands(self): with open("doc.txt", "w") as handle: handle.write("x") seen = [] with self.fake_chat(seen): self.agent.run_turn("explain @doc.txt briefly", capture=True) user_text = seen[0][-1] self.assertIn(os.path.realpath("doc.txt"), user_text) self.assertNotIn("@doc.txt", user_text) def test_midtext_prose_untouched(self): seen = [] with self.fake_chat(seen): self.agent.run_turn("ping me @home base", capture=True) self.assertEqual(seen[0][-1], "ping me @home base") def test_missing_midtext_path_stays_literal(self): seen = [] with self.fake_chat(seen): self.agent.run_turn("read @nope-missing-1.txt now", capture=True) self.assertIn("@nope-missing-1.txt", seen[0][-1]) def test_leading_file_tag(self): with open("doc.txt", "w") as handle: handle.write("x") seen = [] with self.fake_chat(seen): self.agent.run_turn("@doc.txt summarize", capture=True) self.assertTrue(seen[0][-1].startswith(os.path.realpath("doc.txt"))) def test_pins_filter_candidates(self): cands = [ {"provider": "opencode", "chat_url": "opencode://run", "models_url": "x", "key": "", "model": "a", "profile": ""}, {"provider": "ollama", "chat_url": "http://x", "models_url": "y", "key": "", "model": "b", "profile": ""}, ] with mock.patch.object(self.agent.chat, "candidates", return_value=cands): self.agent.chat.provider_pin = "opencode" got = self.agent.chat.ordered_candidates() self.assertEqual([cand["provider"] for cand in got], ["opencode", "ollama"]) self.agent.chat.provider_pin = None self.agent.chat.model_pin = "b" got = self.agent.chat.all_candidates() self.assertEqual([cand["model"] for cand in got], ["b", "a"]) def test_unmatched_pin_falls_back_to_all(self): cands = [{"provider": "ollama", "chat_url": "http://x", "models_url": "y", "key": "", "model": "b", "profile": ""}] with mock.patch.object(self.agent.chat, "candidates", return_value=cands): self.agent.chat.provider_pin = "no-such-provider" got = self.agent.chat.ordered_candidates() self.assertEqual([cand["model"] for cand in got], ["b"]) def test_no_candidates_raise(self): with mock.patch.object(self.agent.chat, "candidates", return_value=[]): self.agent.chat.provider_pin = "no-such-provider" with self.assertRaises(tai.BackendError) as raised: self.agent.chat.complete([{"role": "user", "content": "hi"}]) self.assertIn("no-such-provider", str(raised.exception)) def test_dir_defaults_roundtrip(self): self.store.save_dir_defaults("d1", provider="opencode") self.store.save_dir_defaults("d1", bot="coder") self.assertEqual(self.store.load_dir_defaults("d1"), {"provider": "opencode", "bot": "coder"}) self.store.save_dir_defaults("d1", clear=("provider",)) self.assertEqual(self.store.load_dir_defaults("d1"), {"bot": "coder"}) self.store.save_dir_defaults("d1", clear=("bot",)) self.assertEqual(self.store.load_dir_defaults("d1"), {}) def test_apply_dir_defaults(self): self.tools_dispatch_create_bot() self.store.save_dir_defaults(tai.dir_key(), provider="opencode", model="mimo-v2.6-flash-free", bot="coder") note = tai.apply_dir_defaults(self.agent) self.assertIn("dir defaults", note) self.assertEqual(self.agent.chat.provider_pin, "opencode") self.assertEqual(self.agent.chat.model_pin, "mimo-v2.6-flash-free") self.assertEqual(self.agent.bot, "coder") def test_apply_dir_defaults_skips_unknown_bot(self): self.store.save_dir_defaults(tai.dir_key(), bot="ghost-zzz") note = tai.apply_dir_defaults(self.agent) self.assertEqual(note, "") self.assertEqual(self.agent.bot, "main") def test_apply_dir_defaults_env_model_wins(self): self.config.explicit_model = "env-model-1" self.store.save_dir_defaults(tai.dir_key(), model="mimo-v2.6-flash-free") tai.apply_dir_defaults(self.agent) self.assertIsNone(self.agent.chat.model_pin) def test_complete_mention(self): self.tools_dispatch_create_bot() with open("doc.txt", "w") as handle: handle.write("x") self.assertEqual(tai.complete_mention("@op", 0, self.agent), "@opencode") self.assertEqual(tai.complete_mention("@cod", 0, self.agent), "@coder") self.assertEqual(tai.complete_mention("@doc", 0, self.agent), "@doc.txt") self.assertIsNone(tai.complete_mention("@doc", 5, self.agent)) self.assertIsNone(tai.complete_mention("xx", 0, self.agent)) self.assertEqual(tai.complete_input("/he", 0, None), "/help") self.assertEqual(tai.complete_input("@op", 0, self.agent), "@opencode") def test_loud_paths_print(self): self.agent.quiet = False with mock.patch("builtins.print") as shown: self.agent.run_turn("@ghost-zzz hi", capture=False) self.agent.run_turn("@opencode", capture=False) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list if call.args) self.assertIn("unknown bot @ghost-zzz", printed) self.assertIn("provider pinned to opencode", printed) def test_complete_files(self): os.makedirs("src/nest", exist_ok=True) with open("src/main.py", "w") as handle: handle.write("x") with open(".hidden", "w") as handle: handle.write("x") self.assertEqual(tai.complete_files("src/ma", os.getcwd()), ["src/main.py"]) self.assertEqual(tai.complete_files("src/", os.getcwd()), ["src/main.py", "src/nest/"]) self.assertNotIn(".hidden", tai.complete_files("", os.getcwd())) self.assertIn(".hidden", tai.complete_files(".", os.getcwd())) class OrientationTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name self.workdir = tempfile.TemporaryDirectory() self.saved_cwd = os.getcwd() os.chdir(self.workdir.name) class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "orient-test-1")) self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) def tearDown(self): os.chdir(self.saved_cwd) self.store.close() self.workdir.cleanup() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_block_states_cwd_and_scope(self): block = tai.orientation_block() self.assertIn(os.path.realpath(os.getcwd()), block) self.assertIn("Stay inside it unless the user names another path", block) self.assertIn("scratch or temp dir", block) def test_block_sandbox_variant(self): block = tai.orientation_block(sandbox=True) self.assertIn("Sandboxed container", block) self.assertNotIn(os.path.realpath(os.getcwd()), block) def test_apply_system_injects_block(self): system = self.agent.messages[0]["content"] self.assertIn("## Working directory", system) self.assertIn(os.path.realpath(os.getcwd()), system) def test_reset_history_keeps_block(self): self.agent.messages.append({"role": "user", "content": "hi"}) self.agent.reset_history() self.assertEqual(len(self.agent.messages), 1) self.assertIn("## Working directory", self.agent.messages[0]["content"]) def test_muse_prompt_carries_block(self): prompt = tai.muse_prompt(self.agent.messages, []) self.assertIn(os.path.realpath(os.getcwd()), prompt) def test_muse_prompt_forbids_harness_tools(self): prompt = tai.muse_prompt([{"role": "user", "content": "hi"}], []) self.assertIn("never invoke assistant or harness tools yourself", prompt) class CapabilityTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "cap-test-1")) self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_yolo_variant(self): block = tai.capability_block(yolo=True) self.assertIn("Approvals are off", block) self.assertIn("not read-only", block) self.assertIn("only a failed tool call proves a limit", block) def test_default_variant(self): block = tai.capability_block() self.assertIn("ask for approval", block) self.assertIn("not read-only", block) def test_apply_system_injects_capabilities(self): system = self.agent.messages[0]["content"] self.assertIn("## Session capabilities", system) self.assertIn("ask for approval", system) self.agent.yolo = True self.agent.apply_system() self.assertIn("Approvals are off", self.agent.messages[0]["content"]) def test_run_turn_refreshes_capabilities(self): self.agent.messages[0] = {"role": "system", "content": "stale"} reply = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(self.agent.chat, "complete", return_value=dict(reply)): self.agent.run_turn("hi", capture=True) system = self.agent.messages[0]["content"] self.assertIn("## Session capabilities", system) self.assertNotIn("stale", system) class FTSSearchTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) self.app = FakeSecretApp(self.store) self.app.profile = "t" self.app.config = self.config self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def mem_id(self, text): return re.search(r"mem:[0-9a-f]{16}", text).group(0) def test_triggers_sync_index(self): self.assertTrue(self.store.fts_ok) record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "tangerine", "content": "alpha zonk"}))) self.assertEqual(len(self.store.fts_search("zonk", ("record",), "t", 5)), 1) self.store.upsert_file_record("/tmp/x.txt", "beta zonk") self.assertEqual(len(self.store.fts_search("zonk", ("record",), "t", 5)), 2) self.store.delete_record(record_id, "t") hits = self.store.fts_search("zonk", ("record",), "t", 5) self.assertEqual(len(hits), 1) self.assertNotEqual(hits[0]["item"], record_id) self.store.log_event("t", "user", "message", "zonk event here") event_hits = self.store.fts_search("zonk", ("event",), "t", 5) self.assertEqual(len(event_hits), 1) self.assertIn("[zonk]", event_hits[0]["snippet"]) found_events = self.store.search_events("t", "zonk event") self.assertEqual(len(found_events), 1) self.assertIn("zonk event here", found_events[0][3]) self.assertNotIn("tai1$", found_events[0][3]) path = os.path.join(self.tmp.name, "zonk.txt") self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x"})) found = self.store.audit_search("zonk", "t", 5) self.assertEqual(len(found), 1) self.assertEqual(found[0]["path"], path) def test_ranked_order_and_snippet(self): self.store.add_record("note", "sparse", "quasar convenes", [], "t") self.store.add_record("note", "dense", "quasar " * 20, [], "t") hits = self.store.fts_search("quasar", ("record",), "t", 5) self.assertEqual(len(hits), 2) self.assertEqual(hits[0]["title"], "dense") self.assertIn("[quasar]", hits[0]["snippet"]) def test_unified_search_mixed_and_expand(self): first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "plan", "content": "harbor launch", "tags": ["harbor"]}))) second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "log", "content": "harbor diary", "tags": ["harbor"]}))) self.store.log_event("t", "user", "message", "harbor standup notes") out = self.tools.dispatch("search", json.dumps({"query": "harbor"})) self.assertIn("[record/", out) self.assertIn(first, out) self.assertIn("[event/", out) narrowed = self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["event"]})) self.assertIn("[event/", narrowed) self.assertNotIn("[record/", narrowed) expanded = self.tools.dispatch("search", json.dumps({"query": "harbor launch", "kinds": ["record"], "expand": True})) self.assertIn(first, expanded) self.assertIn("linked:", expanded) self.assertIn("log", expanded) self.assertIn(second, self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["record"], "expand": False}))) def test_record_search_fallback_partial_token(self): record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "soup", "content": "alphabet soup serving", "tags": ["lunch"]}))) found = self.tools.dispatch("record_search", json.dumps({"query": "alphab", "tags": ["lunch"]})) self.assertIn(record_id, found) def test_recall_sealed_fallback(self): other_home = os.path.join(self.tmp.name, "sealed") os.makedirs(other_home) class SealedArgs: profile = "t" yes = True with mock.patch.dict(os.environ, {"TAI_HOME": other_home}): config = tai.Config(SealedArgs()) sealed = tai.Store(config, tai.Seal(config.home, "sealed-pw-3")) try: sealed.log_event("t", "user", "message", "sealed recall marker words") rows = sealed.search_events("t", "recall marker") self.assertEqual(len(rows), 1) self.assertIn("sealed recall marker", rows[0][3]) finally: sealed.close() def test_backfill_on_reopen(self): self.store.add_record("note", "keep", "backfill beacon", [], "t") self.store.db.execute("DELETE FROM fts_docs") self.store.db.commit() self.assertEqual(self.store.fts_search("beacon", ("record",), "t", 5), []) self.store.close() reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) try: hits = reopened.fts_search("beacon", ("record",), "t", 5) self.assertEqual(len(hits), 1) finally: reopened.close() self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) def test_search_tool_validation(self): self.assertIn("empty query", self.tools.dispatch("search", json.dumps({"query": ""}))) self.assertIn("kinds must be", self.tools.dispatch("search", json.dumps({"query": "x", "kinds": ["nope"]}))) self.assertIn("invalid limit", self.tools.dispatch("search", json.dumps({"query": "x", "limit": "z"}))) self.assertIn("no matches", self.tools.dispatch("search", json.dumps({"query": "zzz-nothing-here"}))) def test_audit_query(self): path = os.path.join(self.tmp.name, "queryme.txt") self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x"})) out = self.tools.dispatch("audit", json.dumps({"query": "queryme"})) self.assertIn("queryme.txt", out) def test_search_lazy_tag(self): found = {schema["function"]["name"] for schema in tai.select_tools("find everything about the plan")} self.assertIn("search", found) def test_search_repl(self): self.tools.dispatch("record_save", json.dumps({"title": "repl", "content": "repl beacon words"})) self.app.tools = self.tools with mock.patch("builtins.print") as shown: tai.handle_command(self.app, "/search repl beacon") tai.handle_command(self.app, "/search") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("[record/", printed) self.assertIn("use /search ", printed) def test_mem_index_ranked_sealed(self): self.assertIsNotNone(self.store.memdb) self.store.log_event("t", "user", "message", "sparse comet sighting") self.store.log_event("t", "user", "message", "comet " * 15) hits = self.store.fts_search("comet", ("event",), "t", 5) self.assertEqual(len(hits), 2) self.assertLess(hits[0]["rank"], hits[1]["rank"]) self.assertIn("[comet]", hits[0]["snippet"]) self.assertNotIn("tai1$", hits[0]["snippet"]) stored = self.store.db.execute("SELECT text FROM events WHERE text LIKE 'tai1$%'").fetchall() self.assertEqual(len(stored), 2) def test_mem_index_cross_process_sync(self): self.store.log_event("t", "user", "message", "first syncable event") other = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) try: other.log_event("t", "user", "message", "second syncable event") finally: other.close() hits = self.store.fts_search("syncable", ("event",), "t", 5) self.assertEqual(len(hits), 2) def test_mem_index_profile_scoped(self): self.store.log_event("t", "user", "message", "scoped beacon words") self.store.log_event("other", "user", "message", "scoped beacon words") self.assertEqual(len(self.store.fts_search("beacon", ("event",), "t", 5)), 1) self.assertEqual(len(self.store.fts_search("beacon", ("event",), "other", 5)), 1) def test_mem_index_survives_rotation(self): self.store.log_event("t", "user", "message", "rotation beacon words") old = tai.Seal(self.config.home, "fts-test-1") self.store.close() tai.rotate_seal(self.config, old, "fts-test-2") reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) try: hits = reopened.fts_search("rotation", ("event",), "t", 5) self.assertEqual(len(hits), 1) self.assertIn("[rotation]", hits[0]["snippet"]) finally: reopened.close() self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) def test_run_search_sealed_events_ranked_and_partial(self): self.store.log_event("t", "user", "message", "harbor standup notes") out = self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["event"]})) self.assertIn("[event/", out) self.assertIn("[harbor]", out) partial = self.tools.dispatch("search", json.dumps({"query": "standu", "kinds": ["event"]})) self.assertIn("[event/", partial) self.assertIn("standup", partial) def test_rotate_multi_profile_secrets(self): self.store.save_secret("api", "t-secret-1", None, None, "t") self.store.save_secret("api", "o-secret-2", None, None, "other") old = tai.Seal(self.config.home, "fts-test-1") self.store.close() tai.rotate_seal(self.config, old, "fts-test-2") reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) try: self.assertEqual(reopened.load_secret("api", "t"), "t-secret-1") self.assertEqual(reopened.load_secret("api", "other"), "o-secret-2") finally: reopened.close() self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) class StreamTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "stream-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_truncate_ansi_keeps_short_colors(self): text = "\033[31mhi\033[0m" self.assertEqual(tai.truncate_ansi(text, 10), text) def test_truncate_ansi_cuts_visible_width(self): cut = tai.truncate_ansi("\033[31m" + "x" * 50 + "\033[0m", 10) self.assertEqual(tai.strip_ansi(cut), "x" * 10) self.assertTrue(cut.endswith(tai.Ansi.RESET)) self.assertIn("\033[31m", cut) def test_truncate_ansi_plain(self): self.assertEqual(tai.truncate_ansi("abcdef", 4), "abcd" + tai.Ansi.RESET) def test_human_size(self): self.assertEqual(tai.human_size(0), "0 B") self.assertEqual(tai.human_size(512), "512 B") self.assertEqual(tai.human_size(1024), "1.0 KB") self.assertEqual(tai.human_size(1536), "1.5 KB") self.assertEqual(tai.human_size(2097152), "2.0 MB") def test_stream_status(self): self.assertEqual(tai.stream_status(0, "a\nb\n", 1.25, False), "exit 0 · 1.2s · 2 lines · 4 B") self.assertEqual(tai.stream_status(3, "", 0.0, False), "exit 3 · 0.0s · 0 lines · 0 B") self.assertEqual(tai.stream_status(-9, "x\n" * 10, 120.0, True), "timed out after 120s (killed) · 10 lines · 20 B") self.assertEqual(tai.stream_status(0, "z" * 2048, 0.5, False), "exit 0 · 0.5s · 1 lines · 2.0 KB") def test_window_rolls_at_height(self): buf = io.StringIO() stream = tai.LiveStream(height=2, width=40, file=buf, enabled=True) stream.feed("one\n") stream.feed("two\n") stream.feed("three\n") stream.close("exit 0") out = buf.getvalue() self.assertIn("│ one\n│ two\n", out) self.assertIn("\033[2A", out) self.assertIn("\r\033[K│ three\n", out) self.assertTrue(out.endswith("│ exit 0\n")) def test_window_disabled_writes_nothing(self): buf = io.StringIO() stream = tai.LiveStream(file=buf, enabled=False) stream.feed("one\n") stream.close("exit 0") self.assertEqual(buf.getvalue(), "") def test_window_no_output_status(self): buf = io.StringIO() stream = tai.LiveStream(file=buf, enabled=True) stream.close("exit 3") self.assertEqual(buf.getvalue(), "│ (no output)\n│ exit 3\n") def test_window_keeps_progress_tail(self): buf = io.StringIO() stream = tai.LiveStream(height=4, width=40, file=buf, enabled=True) stream.feed("50%\r100%\n") self.assertIn("│ 100%\n", buf.getvalue()) def test_run_live_captures(self): code, out, _elapsed, timed = tai.run_live(["echo", "hi"]) self.assertEqual((code, out, timed), (0, "hi\n", False)) def test_run_live_exit_code(self): code, _out, _elapsed, timed = tai.run_live(["sh", "-c", "exit 3"]) self.assertEqual((code, timed), (3, False)) def test_run_live_merges_stderr(self): code, out, _elapsed, _timed = tai.run_live(["sh", "-c", "echo out; echo err >&2"]) self.assertIn("out\n", out) self.assertIn("err\n", out) self.assertEqual(code, 0) def test_run_live_streams(self): buf = io.StringIO() stream = tai.LiveStream(file=buf, enabled=True) _code, out, _elapsed, timed = tai.run_live(["echo", "streamed"], stream=stream) stream.close("exit 0") self.assertEqual(out, "streamed\n") self.assertFalse(timed) self.assertIn("│ streamed\n", buf.getvalue()) def test_run_live_timeout_kills(self): started = time.time() _code, _out, _elapsed, timed = tai.run_live(["sleep", "30"], timeout=1) self.assertTrue(timed) self.assertLess(time.time() - started, 10) def test_run_live_pty_answers_prompt(self): asked = [] def ask(text, secret): asked.append((text, secret)) return "yes" cmd = "echo start; printf 'Continue? [y/n] '; read a; echo got:$a" code, out, _elapsed, timed = tai.run_live(cmd, shell=True, ask=ask) self.assertEqual((code, timed, asked), (0, False, [("Continue? [y/n]", False)])) self.assertIn("start\n", out) self.assertIn("got:yes\n", out) def test_run_live_pty_secret_prompt_hidden(self): asked = [] cmd = "stty -echo; printf 'Password: '; read p; stty echo; echo; echo len:${#p}" _code, out, _elapsed, _timed = tai.run_live(cmd, shell=True, ask=lambda text, secret: asked.append((text, secret)) or "hunter2") self.assertEqual(asked, [("Password:", True)]) self.assertIn("len:7", out) self.assertNotIn("hunter2", out) def test_run_live_pty_declined_sends_eof(self): _code, out, _elapsed, timed = tai.run_live("printf 'name: '; read a; echo [$a]", shell=True, ask=lambda text, secret: None) self.assertFalse(timed) self.assertIn("[]", out) def test_run_live_pty_wait_not_counted_in_timeout(self): def slow(text, secret): time.sleep(1.5) return "ok" code, out, _elapsed, timed = tai.run_live("printf 'go? '; read a; echo $a", shell=True, timeout=2, ask=slow) self.assertEqual((code, timed), (0, False)) self.assertIn("ok", out) def test_run_live_pty_progress_is_not_a_prompt(self): asked = [] _code, out, _elapsed, _timed = tai.run_live("printf '50%%\\r'; sleep 1.5; printf '100%%\\n'", shell=True, ask=lambda text, secret: asked.append(text)) self.assertEqual(asked, []) self.assertEqual(out, "100%\n") def test_run_live_pty_timeout_kills_group(self): started = time.time() _code, _out, _elapsed, timed = tai.run_live("sleep 30; echo x", shell=True, timeout=1, ask=lambda text, secret: None) self.assertTrue(timed) self.assertLess(time.time() - started, 10) def test_window_pause_starts_fresh_window(self): buf = io.StringIO() stream = tai.LiveStream(height=2, width=40, file=buf, enabled=True) stream.feed("a\n") stream.pause() stream.feed("b\n") self.assertNotIn("\033[", buf.getvalue()) def test_shell_live_flag(self): app = FakeSecretApp(self.store) app.profile = "t" tools = tai.Tools(app) self.assertFalse(tools.live) tools.live = True buf = io.StringIO() buf.isatty = lambda: True with mock.patch.object(sys, "stdout", buf): result = tools.dispatch("shell", json.dumps({"command": "echo live-mark"})) self.assertTrue(result.startswith("exit 0")) self.assertIn("live-mark", result) self.assertIn("│ live-mark\n", buf.getvalue()) self.assertIn("exit 0 ·", buf.getvalue()) self.assertIn("1 lines · 10 B", buf.getvalue()) def test_shell_silent_without_flag(self): app = FakeSecretApp(self.store) app.profile = "t" tools = tai.Tools(app) buf = io.StringIO() buf.isatty = lambda: True with mock.patch.object(sys, "stdout", buf): result = tools.dispatch("shell", json.dumps({"command": "echo quiet-mark"})) self.assertTrue(result.startswith("exit 0")) self.assertEqual(buf.getvalue(), "") def test_agent_enables_live_for_turn(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=True) reply = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", return_value=dict(reply)): agent.run_turn("hi", capture=True) self.assertFalse(agent.tools.live) agent.quiet = False with mock.patch.object(agent.chat, "complete", return_value=dict(reply)): with mock.patch("builtins.print"): agent.run_turn("hi") self.assertTrue(agent.tools.live) class ProgressTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "progress-test-1")) self.agent = tai.Agent(self.config, self.store, persist=False, quiet=True) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def tool_reply(self, command, call_id="1"): return {"role": "assistant", "content": "", "reasoning": "", "tool_calls": [{"id": call_id, "name": "shell", "arguments": json.dumps({"command": command})}], "backend": "x"} def text_reply(self, text="all done"): return {"role": "assistant", "content": text, "reasoning": "", "tool_calls": [], "backend": "x"} def tool_count(self): return len([item for item in self.agent.messages if item["role"] == "tool"]) def test_novelty_resets_stall(self): replies = [self.tool_reply("echo novel-%d" % num, str(num)) for num in range(5)] + [self.text_reply()] with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): answer = self.agent.run_turn("go", capture=True, max_steps=3) self.assertEqual(answer, "all done") self.assertEqual(self.tool_count(), 5) def test_loop_nudge_then_stop(self): replies = [self.tool_reply("echo same", str(num)) for num in range(6)] + [self.text_reply()] with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): answer = self.agent.run_turn("go", capture=True, max_steps=10) self.assertIn("stuck in a loop", answer) self.assertEqual(self.tool_count(), 5) nudges = [item for item in self.agent.messages if item["role"] == "user" and "loop warning" in item.get("content", "")] self.assertEqual(len(nudges), 1) def test_nudge_recovery(self): replies = [self.tool_reply("echo same", str(num)) for num in range(3)] + [self.tool_reply("echo different", "9"), self.text_reply()] with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): answer = self.agent.run_turn("go", capture=True, max_steps=10) self.assertEqual(answer, "all done") self.assertEqual(self.tool_count(), 4) nudges = [item for item in self.agent.messages if item["role"] == "user" and "loop warning" in item.get("content", "")] self.assertEqual(len(nudges), 1) def test_stall_oscillation(self): replies = [] for num in range(8): replies.append(self.tool_reply("echo osc-%d" % (num % 2), str(num))) replies.append(self.text_reply()) with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): answer = self.agent.run_turn("go", capture=True, max_steps=3) self.assertIn("no progress for 3 steps", answer) self.assertEqual(self.tool_count(), 5) def test_user_interaction_resets(self): replies = [self.tool_reply("echo int-%d" % (num % 2), str(num)) for num in range(8)] + [self.text_reply()] real_dispatch = self.agent.tools.dispatch calls = [] def approving(name, raw): calls.append(name) if len(calls) <= 3: self.agent.user_stepped_in = True return real_dispatch(name, raw) with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): with mock.patch.object(self.agent.tools, "dispatch", side_effect=approving): answer = self.agent.run_turn("go", capture=True, max_steps=2) self.assertIn("no progress for 2 steps", answer) self.assertEqual(self.tool_count(), 5) def test_total_cap_backstop(self): replies = [self.tool_reply("echo fresh-%d" % num, str(num)) for num in range(8)] + [self.text_reply()] with mock.patch.object(tai.Consts, "TOTAL_STEP_CAP", 6): with mock.patch.object(self.agent.chat, "complete", side_effect=[dict(item) for item in replies]): answer = self.agent.run_turn("go", capture=True, max_steps=100) self.assertEqual(answer, "") self.assertEqual(self.tool_count(), 6) def test_ask_approval_marks_interaction(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) self.assertFalse(agent.user_stepped_in) fake_stdin = io.StringIO("y\n") fake_stdin.isatty = lambda: True with mock.patch.object(sys, "stdin", fake_stdin): with mock.patch("builtins.print"): self.assertTrue(agent.ask_approval("echo hi")) self.assertTrue(agent.user_stepped_in) auto_agent = tai.Agent(self.config, self.store, persist=True, quiet=True, auto=True) self.assertTrue(auto_agent.ask_approval("echo hi")) self.assertFalse(auto_agent.user_stepped_in) class WebFetchTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "fetch-test-1")) self.app = FakeSecretApp(self.store) self.app.profile = "t" self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def fake_open(self, body, status=200, content_type="text/html; charset=utf-8", seen=None): class FakeResponse: def __enter__(self): return self def __exit__(self, *exc): return False def read(self, limit=0): return body def fake_urlopen(request, timeout=30): if seen is not None: seen["method"] = request.get_method() seen["data"] = request.data seen["headers"] = {key.lower(): value for key, value in request.headers.items()} seen["timeout"] = timeout response = FakeResponse() response.status = status response.headers = {"Content-Type": content_type} return response return fake_urlopen def test_fetch_post_json(self): seen = {} payload = b'{"ok": true, "tag": ""}' with mock.patch("urllib.request.urlopen", side_effect=self.fake_open(payload, 201, "application/json", seen)): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/items", "method": "post", "headers": {"Content-Type": "application/json", "X-Trace": "1"}, "body": payload.decode("utf-8")})) self.assertEqual(seen["method"], "POST") self.assertEqual(seen["data"], payload) self.assertEqual(seen["headers"]["content-type"], "application/json") self.assertEqual(seen["headers"]["x-trace"], "1") self.assertTrue(result.startswith("HTTP 201 · application/json · 29 B\n")) self.assertIn("", result) def test_fetch_html_strips(self): with mock.patch("urllib.request.urlopen", side_effect=self.fake_open(b"

Hi

")): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://example.test/"})) self.assertTrue(result.startswith("HTTP 200 · text/html · ")) self.assertIn("Hi", result) self.assertNotIn("

", result) self.assertNotIn("var x", result) def test_fetch_raw_keeps_tags(self): with mock.patch("urllib.request.urlopen", side_effect=self.fake_open(b"

Hi

")): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://example.test/", "raw": True})) self.assertIn("

Hi

", result) def test_fetch_validation(self): base = "https://example.test/" self.assertIn("method must be", self.tools.dispatch("web_fetch", json.dumps({"url": base, "method": "BREW"}))) self.assertIn("headers must be", self.tools.dispatch("web_fetch", json.dumps({"url": base, "headers": ["x"]}))) self.assertIn("headers must be", self.tools.dispatch("web_fetch", json.dumps({"url": base, "headers": {"X-A": 1}}))) self.assertIn("invalid header", self.tools.dispatch("web_fetch", json.dumps({"url": base, "headers": {"Bad Name": "x"}}))) self.assertIn("invalid header", self.tools.dispatch("web_fetch", json.dumps({"url": base, "headers": {"X-A": "a\nb"}}))) self.assertIn("body must be", self.tools.dispatch("web_fetch", json.dumps({"url": base, "method": "POST", "body": 42}))) self.assertIn("invalid timeout", self.tools.dispatch("web_fetch", json.dumps({"url": base, "timeout": "x"}))) self.assertIn("url must start", self.tools.dispatch("web_fetch", json.dumps({"url": "ftp://example.test/"}))) def test_fetch_http_error_surfaces_body(self): failure = urllib.error.HTTPError("https://example.test/", 404, "Not Found", None, io.BytesIO(b"no such widget")) with mock.patch("urllib.request.urlopen", side_effect=failure): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://example.test/"})) self.assertEqual(result, "error: HTTP 404: no such widget") empty = urllib.error.HTTPError("https://example.test/", 500, "Server Error", None, io.BytesIO(b"")) with mock.patch("urllib.request.urlopen", side_effect=empty): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://example.test/"})) self.assertTrue(result.startswith("error: HTTP 500:")) def test_fetch_connection_error(self): with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("refused")): result = self.tools.dispatch("web_fetch", json.dumps({"url": "https://example.test/"})) self.assertIn("error: fetch failed:", result) def test_shell_curl_hint(self): marked = self.tools.dispatch("shell", json.dumps({"command": "echo curl http://example.test"})) self.assertIn(tai.CURL_HINT, marked) plain = self.tools.dispatch("shell", json.dumps({"command": "echo just-local"})) self.assertNotIn("web_fetch", plain) self.assertEqual(tai.curl_hint("wget https://example.test/x"), "\n" + tai.CURL_HINT) self.assertEqual(tai.curl_hint("curl --version"), "") self.assertEqual(tai.curl_hint("echo hi"), "") def test_steering_text(self): descs = {schema["function"]["name"]: schema["function"]["description"] for schema in tai.TOOL_SCHEMAS} self.assertIn("never curl or wget", descs["web_fetch"]) self.assertIn("use web_fetch for HTTP(S)", descs["shell"]) self.assertIn("never curl or wget", tai.DEFAULT_SYSTEM) class DiffTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "diff-test-1")) self.app = FakeSecretApp(self.store) self.app.profile = "t" self.tools = tai.Tools(self.app) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_highlight_python(self): out = tai.highlight_python("def f(x=1): # hi") self.assertIn(tai.Ansi.MAGENTA + "def" + tai.Ansi.RESET, out) self.assertIn(tai.Ansi.CYAN + "f" + tai.Ansi.RESET, out) self.assertIn(tai.FG_ORANGE + "1" + tai.Ansi.RESET, out) self.assertIn(tai.Ansi.GRAY + "# hi" + tai.Ansi.RESET, out) self.assertEqual(tai.strip_ansi(out), "def f(x=1): # hi") def test_highlight_string_with_hash(self): out = tai.highlight_python("x = 'a # b'") self.assertIn(tai.Ansi.YELLOW + "'a # b'" + tai.Ansi.RESET, out) self.assertNotIn(tai.Ansi.GRAY, out) def test_render_diff_plain(self): out = tai.render_diff("a.py", "x = 1\n", "x = 2\n", width=60, colors=False) self.assertEqual(out, "│ ── a.py (+1 -1)\n 1 - x = 1\n 1 + x = 2") def test_render_diff_colors(self): out = tai.render_diff("a.py", "x = 1\n", "x = 2\n", width=60, colors=True) self.assertIn(tai.BG_ADD, out) self.assertIn(tai.BG_DEL, out) self.assertIn(tai.FG_ORANGE + "2" + tai.Ansi.RESET, out) for line in out.splitlines()[1:]: self.assertEqual(len(tai.strip_ansi(line)), 60) def test_render_diff_gap_and_cap(self): old = "".join("line %d\n" % num for num in range(20)) new = old.replace("line 0\n", "line zero\n").replace("line 19\n", "line nineteen\n") out = tai.render_diff("a.py", old, new, width=60, colors=False) self.assertIn("···", out) big = "".join("row %d\n" % num for num in range(200)) capped = tai.render_diff("a.py", "", big, width=60, colors=False) self.assertIn("more lines hidden", capped) self.assertEqual(len(capped.splitlines()), 1 + tai.DIFF_MAX_LINES + 1) def test_render_diff_shapes(self): self.assertEqual(tai.render_diff("x.py", "a\n", "a\n"), "") created = tai.render_diff("x.py", None, "a\nb\n", width=60, colors=False) self.assertIn("(new file, 2 lines)", created.splitlines()[0]) removed = tai.render_diff("x.py", "a\nb\n", None, width=60, colors=False) self.assertIn("(deleted, 2 lines)", removed.splitlines()[0]) plain = tai.render_diff("notes.txt", "a\n", "b\n", width=60, colors=True) self.assertIn(tai.BG_ADD, plain) self.assertNotIn(tai.Ansi.MAGENTA, plain) self.assertNotIn(tai.Ansi.YELLOW, plain) def test_render_diff_docstring_fence(self): out = tai.render_diff("a.py", "", '"""\ndoc body\n"""\nx = 1\n', width=60, colors=True) self.assertIn(tai.Ansi.YELLOW + "doc body" + tai.Ansi.RESET, out) def test_tools_note_preview(self): path = os.path.join(self.tmp.name, "note.py") self.assertTrue(self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x = 1\n"})).startswith("wrote ")) preview = self.tools.diff_preview self.assertEqual((preview["path"], preview["old"], preview["new"]), (path, None, "x = 1\n")) self.tools.dispatch("read_file", json.dumps({"path": path})) self.assertTrue(self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "x = 1", "replace": "x = 2"})).startswith("edited ")) preview = self.tools.diff_preview self.assertEqual((preview["old"], preview["new"]), ("x = 1\n", "x = 2\n")) self.assertTrue(self.tools.dispatch("delete_file", json.dumps({"path": path})).startswith("deleted ")) preview = self.tools.diff_preview self.assertEqual((preview["old"], preview["new"]), ("x = 2\n", None)) def test_show_result_renders_and_clears(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) agent.tools.diff_preview = {"path": "a.py", "old": "x = 1\n", "new": "x = 2\n"} with mock.patch.object(sys.stdout, "isatty", return_value=False): with mock.patch("builtins.print") as shown: agent.show_result("ok", 1) agent.show_result("ok", 1) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertEqual(printed.count("── a.py"), 1) self.assertEqual(printed.count("└─"), 2) self.assertNotIn("\033", printed) self.assertIsNone(agent.tools.diff_preview) agent.quiet = True agent.tools.diff_preview = {"path": "a.py", "old": "x = 1\n", "new": "x = 2\n"} with mock.patch("builtins.print") as shown: agent.show_result("ok", 1) shown.assert_not_called() class GlowRenderingTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "glow-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_describe_call_verbs(self): self.assertEqual(tai.Glow.describe_call("shell", json.dumps({"command": "make test\nsecond"})), ("Run", "make test second", "shell", "make test second")) self.assertEqual(tai.Glow.describe_call("read_file", json.dumps({"path": "/tmp/x/main.py"})), ("Read", "main.py", "read_file", "/tmp/x/main.py")) self.assertEqual(tai.Glow.describe_call("shell", {"command": "ls"}), ("Run", "ls", "shell", "ls")) self.assertEqual(tai.Glow.describe_call("shell", "nope{"), ("Run", "", "shell", "nope{")) self.assertEqual(tai.Glow.describe_call("mystery", "{}"), ("mystery", "", "", "{}")) self.assertEqual(tai.Glow.describe_call("poll", {"id": 3}), ("Poll", "3", "poll", "agent 3")) self.assertEqual(tai.Glow.describe_call("write_file", json.dumps({"path": "n.txt", "content": "x" * 500}))[3], "n.txt") verb, target, _extra, preview = tai.Glow.describe_call("store_secret", json.dumps({"name": "api", "value": "s3cr3t"})) self.assertEqual(verb, "Seal") self.assertNotIn("s3cr3t", target) self.assertNotIn("s3cr3t", preview) self.assertIn("[hidden]", preview) def test_shimmer_sweeps(self): out = tai.Glow.shimmer("thinking", 2) self.assertIn(tai.Ansi.BOLD, out) self.assertIn(tai.Ansi.DIM, out) self.assertEqual(tai.strip_ansi(out), "thinking") self.assertEqual(tai.strip_ansi(tai.Glow.shimmer("thinking", 99)), "thinking") self.assertEqual(tai.Glow.shimmer("", 5), "") def test_event_and_done_rows(self): row = tai.Glow.event_row("◆", "Edited", "a.py", "(+1 -1)") self.assertIn("◆", row) self.assertIn(tai.FG_FILE, row) self.assertEqual(tai.strip_ansi(row), "◆ Edited a.py (+1 -1)") done = tai.Glow.done_row("Run", "make test", 12, 234) self.assertIn("✓", done) self.assertIn("234ms", done) self.assertIn("12 chars", done) failed = tai.Glow.done_row("Run", "", 5, 1500, failed=True) self.assertIn("✗", failed) self.assertIn("1.5s", failed) short = tai.Glow.event_row("◆", "Edited", "a.py", "(+1 -1)", width=10) self.assertEqual(len(tai.strip_ansi(short)), 10) def test_kind_icons_and_ellipsis(self): self.assertEqual(tai.Glow.icon_for("shell"), "▶") self.assertEqual(tai.Glow.icon_for("fork"), "✦") self.assertEqual(tai.Glow.icon_for("poll"), "⟳") self.assertEqual(tai.Glow.icon_for("read_file"), "◇") self.assertEqual(tai.Glow.icon_for("mystery"), "◇") self.assertEqual(tai.Glow.ellipsis("short", 60), "short") long = "a" * 30 + "/" + "b" * 30 cut = tai.Glow.ellipsis(long, 20) self.assertEqual(len(cut), 20) self.assertIn("…", cut) self.assertTrue(cut.startswith("a" * 9)) self.assertTrue(cut.endswith("b" * 10)) self.assertEqual(tai.Glow.ellipsis(long, 4), long) self.assertEqual(tai.Glow.ellipsis("a" * 60, 20, head=True), "a" * 19 + "…") def test_accents_and_bars(self): self.assertEqual(tai.Glow.accent_for("shell"), tai.FG_RUN) self.assertEqual(tai.Glow.accent_for("fork"), tai.FG_FORK) self.assertEqual(tai.Glow.accent_for("poll"), tai.FG_FILE) self.assertEqual(tai.Glow.accent_for("edit_file"), tai.Ansi.GREEN) self.assertEqual(tai.Glow.accent_for("ask_user"), tai.FG_ORANGE) self.assertEqual(tai.Glow.accent_for("store_secret"), tai.FG_YOLO) self.assertEqual(tai.Glow.accent_for("read_file"), tai.Ansi.DIM) bar = tai.Glow.bar("shell") self.assertIn("▌", bar) self.assertIn(tai.FG_RUN, bar) tinted = tai.Glow.event_row("▶", "Run", "x", "", 0, tai.FG_RUN) self.assertIn(tai.Ansi.BOLD + tai.FG_RUN + "▶", tinted) self.assertEqual(tai.strip_ansi(tinted), "▶ Run x") def test_done_row_right_aligns_meta(self): row = tai.Glow.done_row("Run", "x", 12, 234, width=60) plain = tai.strip_ansi(row) self.assertEqual(len(plain), 60) self.assertTrue(plain.startswith("◆ Run x")) self.assertTrue(plain.endswith("✓ · 234ms · 12 chars")) narrow = tai.Glow.done_row("Run", "averylongtargetname", 12, 234, width=10) self.assertEqual(len(tai.strip_ansi(narrow)), 10) def test_agent_row_and_lanes(self): row = tai.Glow.agent_row({"id": 3, "task": "translate p1 to german", "status": "running", "elapsed": 75}) self.assertIn("#3", row) self.assertIn(tai.Ansi.YELLOW + "●" + tai.Ansi.RESET, row) self.assertIn("translate p1 to german", tai.strip_ansi(row)) self.assertIn("1m", tai.strip_ansi(row)) done = tai.Glow.agent_row({"id": 4, "task": "t", "status": "done", "elapsed": 3}) self.assertIn(tai.Ansi.GREEN + "●" + tai.Ansi.RESET, done) many = [{"id": num, "task": "t%d" % num, "status": "running", "elapsed": 1} for num in range(8)] rows = tai.Glow.lanes(many) self.assertEqual(len(rows), 1 + 6 + 1) self.assertIn("8 running", tai.strip_ansi(rows[0])) self.assertIn("+2 more", tai.strip_ansi(rows[-1])) def test_lane_tasks_keep_distinguishing_head(self): task = "Translate README.md lines 94-186 to /tmp/xxx_p2.md. Rules: " + "x" * 80 row = tai.strip_ansi(tai.Glow.agent_row({"id": 1, "task": task, "status": "running", "elapsed": 0})) self.assertIn("lines 94-186", row) self.assertIn("…", row) wide = tai.Glow.agent_row({"id": 1, "task": task, "status": "running", "elapsed": 0}, width=40) self.assertLessEqual(len(tai.strip_ansi(wide)), 40) pulsed = tai.Glow.agent_row({"id": 1, "task": "t", "status": "running", "elapsed": 0}, 0, tai.Ansi.BOLD) self.assertIn(tai.Ansi.BOLD + tai.Ansi.YELLOW + "●", pulsed) def test_lanes_show_pending_pickups(self): records = [{"id": 1, "task": "a", "status": "running", "elapsed": 5, "collected": False}, {"id": 2, "task": "b", "status": "done", "elapsed": 9, "collected": False}, {"id": 3, "task": "c", "status": "done", "elapsed": 9, "collected": True}] rows = tai.Glow.lanes(records) self.assertIn("1 running · 1 to collect", tai.strip_ansi(rows[0])) body = "\n".join(tai.strip_ansi(row) for row in rows[1:]) self.assertIn("#1", body) self.assertIn("#2", body) self.assertNotIn("#3", body) self.assertIn("to collect", body) def test_show_call_truncates_to_width(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("builtins.print") as shown: agent.show_call({"name": "shell", "arguments": json.dumps({"command": "echo " + "x" * 300})}) width = tai.Glow.width() for call in shown.call_args_list: if call.args: self.assertLessEqual(len(tai.strip_ansi(str(call.args[0]))), width) def test_fork_done_row_carries_agent_id(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("builtins.print") as shown: agent.show_call({"name": "fork", "arguments": json.dumps({"task": "translate p2"})}) with mock.patch("tai.list_agents", return_value=[]): agent.show_result("agent 7 started: translate p2", 5) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list if call.args) self.assertIn("#7", printed) def test_show_lanes_snapshot_and_collect(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) running = [{"id": 3, "task": "translate p1 to german", "status": "running", "elapsed": 5}, {"id": 4, "task": "translate p2 to german", "status": "running", "elapsed": 5}] with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("builtins.print") as shown: agent.show_call({"name": "fork", "arguments": json.dumps({"task": "translate p2"})}) with mock.patch("tai.list_agents", return_value=running): agent.show_result("agent 4 started: translate p2", 5) with mock.patch("builtins.print") as collected: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 3})}) with mock.patch("tai.list_agents", return_value=[]): agent.show_result("[done] translated text", 5) with mock.patch("builtins.print") as quiet: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 3})}) with mock.patch("tai.list_agents", return_value=running[:1]): agent.show_result("[done] translated text", 5) with mock.patch("builtins.print") as missing: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 9})}) agent.show_result("[missing] no agent 9", 5) snap = "\n".join(str(call.args[0]) for call in shown.call_args_list if call.args) self.assertIn("✦", snap) self.assertIn("lanes", snap) self.assertIn("#3", snap) self.assertIn("#4", snap) done = "\n".join(str(call.args[0]) for call in collected.call_args_list if call.args) self.assertIn("⟳", done) self.assertIn("all collected", done) lone = "\n".join(str(call.args[0]) for call in quiet.call_args_list if call.args) self.assertNotIn("lanes", lone) gone = "\n".join(str(call.args[0]) for call in missing.call_args_list if call.args) self.assertIn("✗", gone) def test_show_lanes_pending_and_boarded(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) leftover = [{"id": 2, "task": "translate p2", "status": "done", "elapsed": 9, "collected": False}] picked = [{"id": 2, "task": "translate p2", "status": "done", "elapsed": 9, "collected": True}] pair = [{"id": 3, "task": "t3", "status": "running", "elapsed": 5, "collected": False}, {"id": 4, "task": "t4", "status": "running", "elapsed": 5, "collected": False}] with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("builtins.print") as pending: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 1})}) with mock.patch("tai.list_agents", return_value=leftover): agent.show_result("[done] translated text", 5) with mock.patch("builtins.print") as clean: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 2})}) with mock.patch("tai.list_agents", return_value=picked): agent.show_result("[done] translated text", 5) agent.tools.board_shown = True with mock.patch("builtins.print") as boarded: agent.show_call({"name": "poll", "arguments": json.dumps({"id": 3})}) with mock.patch("tai.list_agents", return_value=pair): agent.show_result("[running] agent 3 still running (5s elapsed)", 5000) held = "\n".join(str(call.args[0]) for call in pending.call_args_list if call.args) self.assertIn("to collect", held) self.assertNotIn("all collected", held) swept = "\n".join(str(call.args[0]) for call in clean.call_args_list if call.args) self.assertIn("all collected", swept) live = "\n".join(str(call.args[0]) for call in boarded.call_args_list if call.args) self.assertNotIn("lanes", live) self.assertFalse(agent.tools.board_shown) def test_agents_command_lanes(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) records = [{"id": 7, "task": "translate p3", "status": "running", "elapsed": 9}] with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("tai.list_agents", return_value=records): with mock.patch("builtins.print") as shown: tai.handle_command(agent, "/agents") printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("agents", printed) self.assertIn("#7", printed) self.assertIn("translate p3", printed) def test_statusbar_segments(self): bar = tai.Glow.statusbar("9.9.9", "work", os.path.expanduser("~/proj"), yolo=True, agents=2, width=200) plain = tai.strip_ansi(bar) for bit in ("tai 9.9.9", "work", "~/proj", "YOLO", "+2"): self.assertIn(bit, plain) self.assertIn(tai.FG_YOLO, bar) slim = tai.strip_ansi(tai.Glow.statusbar("9.9.9", "work", "/tmp", width=200)) self.assertNotIn("YOLO", slim) self.assertIn("/tmp", slim) compact = tai.strip_ansi(tai.Glow.statusbar("1.24.0", "t", "/tmp", yolo=True, agents=2, width=34)) self.assertEqual(compact, "tai 1.24.0 · t · YOLO · +2") tiny = tai.strip_ansi(tai.Glow.statusbar("1.24.0", "t", "/tmp", yolo=True, agents=2, width=20)) self.assertFalse(tiny.endswith("·") or tiny.endswith(" ")) def test_prompt_and_hairline(self): self.assertEqual(tai.strip_ansi(tai.Glow.prompt_text()), "› ") self.assertIn(tai.FG_ORANGE, tai.Glow.prompt_text()) self.assertEqual(tai.strip_ansi(tai.Glow.hairline(10)), "─" * 10) def test_render_diff_glow_header_and_rows(self): out = tai.render_diff("a.py", "x = 1\n", "x = 2\n", width=60, colors=True) self.assertTrue(tai.strip_ansi(out.splitlines()[0]).startswith("◆ Edited a.py (+1 -1)")) self.assertIn(tai.FG_ADD, out) self.assertIn(tai.FG_DEL, out) def test_show_call_result_glow_branch(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) self.store.save_secret("api", "known-token-5") with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("builtins.print") as shown: agent.show_call({"name": "shell", "arguments": json.dumps({"command": "curl known-token-5"})}) agent.show_call({"name": "read_file", "arguments": json.dumps({"path": "/tmp/x"})}) agent.show_result("ok", 250) with mock.patch("builtins.print") as failed: agent.show_call({"name": "shell", "arguments": "{}"}) agent.show_result("error: boom", 5) printed = "\n".join(str(call.args[0]) for call in shown.call_args_list if call.args) self.assertIn("▶", printed) self.assertIn("◇", printed) self.assertIn("◆", printed) self.assertIn("▌", printed) self.assertIn("✓", printed) self.assertNotIn('{"command"', printed) self.assertNotIn("known-token-5", printed) self.assertIn("[redacted:api]", printed) busted = "\n".join(str(call.args[0]) for call in failed.call_args_list if call.args) self.assertIn("✗", busted) class LaneBoardTests(unittest.TestCase): def test_available(self): loud = mock.Mock(quiet=False) hushed = mock.Mock(quiet=True) with mock.patch.object(sys.stdout, "isatty", return_value=False): self.assertFalse(tai.LaneBoard.available(loud)) with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): self.assertTrue(tai.LaneBoard.available(loud)) self.assertFalse(tai.LaneBoard.available(hushed)) with mock.patch.dict(os.environ, {"NO_COLOR": "1"}): self.assertFalse(tai.LaneBoard.available(loud)) def test_paint_rewrites_in_place(self): records = [{"id": 1, "task": "translate p1", "status": "running", "elapsed": 5, "collected": False}] buf = io.StringIO() board = tai.LaneBoard("t", width=100, file=buf) with mock.patch("tai.list_agents", return_value=records): board._paint("") first = buf.getvalue() self.assertNotIn("\033[2A", first) self.assertIn("1 running", tai.strip_ansi(first)) board._paint(tai.Ansi.BOLD) second = buf.getvalue() self.assertIn("\033[2A", second) self.assertIn(tai.Ansi.BOLD + tai.Ansi.YELLOW + "●", second) self.assertEqual(board.painted, 2) def test_count_change_degrades_gracefully(self): one = [{"id": 1, "task": "t1", "status": "running", "elapsed": 5, "collected": False}] two = one + [{"id": 2, "task": "t2", "status": "running", "elapsed": 5, "collected": False}] buf = io.StringIO() board = tai.LaneBoard("t", width=100, file=buf) with mock.patch("tai.list_agents", return_value=one): board._paint("") mark = len(buf.getvalue()) with mock.patch("tai.list_agents", return_value=two): board._paint("") fresh = buf.getvalue()[mark:] self.assertNotIn("[A", fresh) self.assertIn("#2", fresh) self.assertEqual(board.painted, 3) def test_start_stop_lifecycle(self): records = [{"id": 1, "task": "translate p1", "status": "running", "elapsed": 5, "collected": False}] buf = io.StringIO() board = tai.LaneBoard("t", width=100, file=buf) with mock.patch("tai.list_agents", return_value=records): board.start() time.sleep(0.5) board.stop() self.assertIsNone(board.worker) self.assertGreaterEqual(buf.getvalue().count("lanes · 1 running"), 2) class ResumeTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "resume-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def text_reply(self, text="done"): return {"role": "assistant", "content": text, "reasoning": "", "tool_calls": [], "backend": "x"} def test_atomic_session_survives_failed_replace(self): first = [{"role": "user", "content": "first"}] self.store.save_session("t", first) with mock.patch("os.replace", side_effect=OSError("disk full")): self.store.save_session("t", [{"role": "user", "content": "second"}]) self.assertEqual([item["content"] for item in self.store.load_session("t")], ["first"]) def test_corrupt_session_quarantined(self): path = self.store.session_path("t") self.store.save_session("t", [{"role": "user", "content": "good"}]) with open(path, "w", encoding="utf-8") as handle: handle.write("{not json") self.assertEqual(self.store.load_session("t"), []) leftovers = [entry for entry in os.listdir(self.config.profiles_dir) if ".corrupt-" in entry] self.assertEqual(len(leftovers), 1) with open(os.path.join(self.config.profiles_dir, leftovers[0]), encoding="utf-8") as handle: self.assertEqual(handle.read(), "{not json") def test_normal_turn_closes_state(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) with mock.patch.object(agent.chat, "complete", return_value=self.text_reply()): agent.run_turn("hello", capture=True) state = self.store.load_turn_state("t", "main") self.assertFalse(state["open"]) self.assertEqual(state["goal"], "hello") def test_crash_resume_end_to_end(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) tool_reply = {"role": "assistant", "content": "", "reasoning": "", "tool_calls": [{"id": "1", "name": "shell", "arguments": json.dumps({"command": "echo resume-mark"})}], "backend": "x"} with mock.patch.object(agent.chat, "complete", side_effect=[dict(tool_reply), KeyboardInterrupt()]): with self.assertRaises(KeyboardInterrupt): agent.run_turn("do the thing", capture=True) state = self.store.load_turn_state("t", "main") self.assertTrue(state["open"]) self.assertEqual(state["goal"], "do the thing") self.store.close() store2 = tai.Store(self.config, tai.Seal(self.config.home, "resume-test-1")) self.store = store2 agent2 = tai.Agent(self.config, store2, persist=True, quiet=True) texts = [item.get("content") or "" for item in agent2.messages] self.assertTrue(any("do the thing" in text for text in texts)) self.assertTrue(any("resume-mark" in text for text in texts)) with mock.patch.object(agent2.chat, "complete", return_value=self.text_reply("finished")): with mock.patch("builtins.print"): answer = tai.resume_turn(agent2) self.assertEqual(answer, "finished") self.assertFalse(store2.load_turn_state("t", "main")["open"]) def test_resume_nothing_open(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) with mock.patch.object(agent.chat, "complete") as called: with mock.patch("builtins.print") as shown: self.assertEqual(tai.resume_turn(agent), "") called.assert_not_called() printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) self.assertIn("nothing to resume", printed) def test_checkpoint_idle_writes_no_state(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) agent.checkpoint() self.assertEqual(self.store.load_turn_state("t", "main"), {}) self.assertFalse(os.path.exists(self.store.turn_state_path("t", "main"))) def test_parser_continue(self): args = tai.build_parser().parse_args(["--continue"]) self.assertTrue(args.resume) self.assertFalse(tai.build_parser().parse_args([]).resume) class DenialTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = False self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def call_reply(self, *calls): return {"role": "assistant", "content": "", "reasoning": "", "tool_calls": list(calls), "backend": "x"} def test_deny_continues_with_guidance(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) first = self.call_reply({"id": "c1", "name": "shell", "arguments": json.dumps({"command": "ssh evil.example.com"})}) final = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", side_effect=[first, final]) as completer: with mock.patch("sys.stdin") as fake_stdin: fake_stdin.isatty.return_value = True with mock.patch("builtins.input", side_effect=["n", "do X instead"]) as asker: with mock.patch("builtins.print"): result = agent.run_turn("try ssh", capture=True) self.assertEqual(result, "done") self.assertEqual(asker.call_count, 2) second_messages = completer.call_args_list[1].args[0] users = [item["content"] for item in second_messages if item["role"] == "user"] self.assertIn("do X instead", users) tools = [item for item in second_messages if item["role"] == "tool"] self.assertEqual(tools[0]["content"], "denied by user") def test_deny_empty_aborts(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True) first = self.call_reply( {"id": "c1", "name": "shell", "arguments": json.dumps({"command": "ssh a.example.com"})}, {"id": "c2", "name": "shell", "arguments": json.dumps({"command": "ssh b.example.com"})}, ) with mock.patch.object(agent.chat, "complete", return_value=first) as completer: with mock.patch("sys.stdin") as fake_stdin: fake_stdin.isatty.return_value = True with mock.patch("builtins.input", side_effect=["n", ""]): with mock.patch("builtins.print"): result = agent.run_turn("try ssh", capture=True) self.assertEqual(result, "stopped by user") self.assertEqual(completer.call_count, 1) by_id = {} for item in agent.messages: for recorded in item.get("tool_calls") or []: by_id[recorded["id"]] = None for item in agent.messages: if item.get("role") == "tool": by_id[item["tool_call_id"]] = item["content"] self.assertEqual(by_id, {"c1": "denied by user", "c2": "skipped: stopped after denial"}) def test_auto_never_asks(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True, auto=True) first = self.call_reply({"id": "c1", "name": "shell", "arguments": json.dumps({"command": "echo auto-ok"})}) final = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", side_effect=[first, final]): with mock.patch("sys.stdin") as fake_stdin: fake_stdin.isatty.return_value = True with mock.patch("builtins.input", side_effect=AssertionError("must not ask")) as asker: with mock.patch("builtins.print"): result = agent.run_turn("try echo", capture=True) self.assertEqual(result, "done") self.assertEqual(asker.call_count, 0) ran = [item for item in agent.messages if item.get("role") == "tool"] self.assertIn("auto-ok", ran[0]["content"]) def test_dispatch_reraises_denied(self): tools = tai.Tools(FakeApp()) def raiser(args): raise tai.Denied("go left") tools.handlers["boom"] = raiser with self.assertRaises(tai.Denied) as caught: tools.dispatch("boom", "{}") self.assertEqual(caught.exception.guidance, "go left") def test_secret_delete_passes_no_guidance(self): agent = mock.Mock() seen = [] def approver(command, guidance=True): seen.append(guidance) return False agent.ask_approval = approver agent.store = self.store agent.profile = "t" self.store.save_secret("wifi", "repl-value-9") with mock.patch("builtins.print"): tai.handle_command(agent, "/secret delete wifi") self.assertEqual(seen, [False]) self.assertEqual(self.store.load_secret("wifi"), "repl-value-9") def test_show_call_masks_secrets(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) self.store.save_secret("api", "known-token-5") with mock.patch("builtins.print") as printer: agent.show_call({"name": "store_secret", "arguments": json.dumps({"name": "api", "value": "brand-new-1"})}) agent.show_call({"name": "shell", "arguments": json.dumps({"command": "curl known-token-5"})}) shown = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertNotIn("brand-new-1", shown) self.assertIn("[hidden]", shown) self.assertNotIn("known-token-5", shown) self.assertIn("[redacted:api]", shown) class FileGuardTests(unittest.TestCase): def test_write_requires_read(self): with tempfile.TemporaryDirectory() as tmp: target = os.path.join(tmp, "notes.txt") with open(target, "w", encoding="utf-8") as handle: handle.write("original one") tools = tai.Tools(FakeApp()) denied = tools.dispatch("write_file", json.dumps({"path": target, "content": "clobber"})) self.assertIn("read it first", denied) with open(target, encoding="utf-8") as handle: self.assertEqual(handle.read(), "original one") self.assertIn("original one", tools.dispatch("read_file", json.dumps({"path": target}))) self.assertIn("wrote", tools.dispatch("write_file", json.dumps({"path": target, "content": "updated two"}))) with open(target, encoding="utf-8") as handle: self.assertEqual(handle.read(), "updated two") def test_new_files_always_writable(self): with tempfile.TemporaryDirectory() as tmp: target = os.path.join(tmp, "sub", "fresh.txt") tools = tai.Tools(FakeApp()) self.assertIn("wrote", tools.dispatch("write_file", json.dumps({"path": target, "content": "hello"}))) self.assertIn("edited", tools.dispatch("edit_file", json.dumps({"path": target, "find": "hello", "replace": "hi"}))) def test_edit_requires_read(self): with tempfile.TemporaryDirectory() as tmp: target = os.path.join(tmp, "code.py") with open(target, "w", encoding="utf-8") as handle: handle.write("print(1)") tools = tai.Tools(FakeApp()) self.assertIn("read it first", tools.dispatch("edit_file", json.dumps({"path": target, "find": "1", "replace": "2"}))) tools.dispatch("read_file", json.dumps({"path": target})) self.assertIn("edited", tools.dispatch("edit_file", json.dumps({"path": target, "find": "1", "replace": "2"}))) def test_guard_is_per_session(self): with tempfile.TemporaryDirectory() as tmp: target = os.path.join(tmp, "data.txt") with open(target, "w", encoding="utf-8") as handle: handle.write("v1") first = tai.Tools(FakeApp()) first.dispatch("read_file", json.dumps({"path": target})) second = tai.Tools(FakeApp()) self.assertIn("read it first", second.dispatch("write_file", json.dumps({"path": target, "content": "v2"}))) class FakeBot: def __init__(self): self.sent = [] self.actions = [] def call(self, method, params, timeout=70): if method == "sendMessage": self.sent.append(params["text"]) if method == "sendChatAction": self.actions.append(params["action"]) return {"ok": True} class FakeAgent: def __init__(self): self.reset = 0 self.turns = [] def reset_history(self): self.reset += 1 def run_turn(self, text, capture=False): self.turns.append((text, capture)) return "canned reply" class TelegramTests(unittest.TestCase): def test_send_chunks(self): bot = FakeBot() tai.telegram_send(bot, 7, "x" * 5000) self.assertEqual([len(part) for part in bot.sent], [4000, 1000]) def test_start_and_new(self): agent = FakeAgent() bot = FakeBot() tai.handle_telegram_update(agent, bot, {"message": {"chat": {"id": 1}, "text": "/start"}}) tai.handle_telegram_update(agent, bot, {"message": {"chat": {"id": 1}, "text": "/new"}}) self.assertEqual(agent.reset, 1) self.assertEqual(len(bot.sent), 2) self.assertEqual(agent.turns, []) def test_text_turn(self): agent = FakeAgent() bot = FakeBot() tai.handle_telegram_update(agent, bot, {"message": {"chat": {"id": 1}, "text": "hello"}}) self.assertEqual(agent.turns, [("hello", True)]) self.assertEqual(bot.sent, ["canned reply"]) self.assertEqual(bot.actions, ["typing"]) def test_token_loading(self): with tempfile.TemporaryDirectory() as home: with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("TELEGRAM_BOT_TOKEN", None) self.assertEqual(tai.load_telegram_token(home), "") with open(os.path.join(home, "telegram.env"), "w", encoding="utf-8") as handle: handle.write("TELEGRAM_BOT_TOKEN=file-token-1\n") self.assertEqual(tai.load_telegram_token(home), "file-token-1") os.environ["TELEGRAM_BOT_TOKEN"] = "env-token-2" self.assertEqual(tai.load_telegram_token(home), "env-token-2") os.environ.pop("TELEGRAM_BOT_TOKEN", None) class InstallTests(unittest.TestCase): def test_bashrc_upsert(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, ".bashrc") with open(path, "w", encoding="utf-8") as handle: handle.write("export PATH=$PATH:/x\n") self.assertTrue(tai.upsert_bashrc_block(path)) self.assertFalse(tai.upsert_bashrc_block(path)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn("export PATH=$PATH:/x", content) self.assertEqual(content.count(tai.BASHRC_MARK_BEGIN), 1) with open(path + ".bak-tai", encoding="utf-8") as handle: self.assertNotIn("tai command-not-found", handle.read()) def test_bashrc_replaces_stale(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, ".bashrc") stale = tai.BASHRC_MARK_BEGIN + "\nold hook\n" + tai.BASHRC_MARK_END + "\n" with open(path, "w", encoding="utf-8") as handle: handle.write("alias x=y\n" + stale) self.assertTrue(tai.upsert_bashrc_block(path)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertNotIn("old hook", content) self.assertIn("alias x=y", content) self.assertEqual(content.count(tai.BASHRC_MARK_BEGIN), 1) def test_shell_rc_path_bash_zsh(self): with mock.patch.dict(os.environ, {"HOME": "/home/x"}, clear=False): self.assertEqual(tai.shell_rc_path("bash"), "/home/x/.bashrc") self.assertEqual(tai.shell_rc_path("zsh"), "/home/x/.zshrc") def test_shell_rc_path_fish_respects_xdg_config_home(self): with mock.patch.dict(os.environ, {"HOME": "/home/x"}, clear=False): os.environ.pop("XDG_CONFIG_HOME", None) self.assertEqual(tai.shell_rc_path("fish"), "/home/x/.config/fish/config.fish") with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/cfg"}): self.assertEqual(tai.shell_rc_path("fish"), "/custom/cfg/fish/config.fish") def test_fish_hook_creates_missing_config_directory(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "fish", "config.fish") self.assertFalse(os.path.isdir(os.path.dirname(path))) self.assertTrue(tai.upsert_fish_hook_block(path)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn("function fish_command_not_found", content) self.assertFalse(tai.upsert_fish_hook_block(path)) def test_zsh_fish_hook_roundtrip_preserves_other_content(self): for shell, upsert, remove, present, needle in ( ("zsh", tai.upsert_zsh_hook_block, tai.remove_zsh_hook_block, tai.zsh_hook_present, "command_not_found_handler"), ("fish", tai.upsert_fish_hook_block, tai.remove_fish_hook_block, tai.fish_hook_present, "fish_command_not_found"), ): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "rc") with open(path, "w", encoding="utf-8") as handle: handle.write("# pre-existing user content\nexport FOO=bar\n") self.assertTrue(upsert(path)) self.assertTrue(present(path)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn("export FOO=bar", content) self.assertIn(needle, content) self.assertTrue(remove(path)) self.assertFalse(present(path)) with open(path, encoding="utf-8") as handle: content = handle.read() self.assertIn("export FOO=bar", content) self.assertNotIn(needle, content, "shell=%s" % shell) def test_parser_prompt(self): args = tai.build_parser().parse_args(["what", "is", "(2+3)?"]) self.assertEqual(args.prompt, ["what", "is", "(2+3)?"]) self.assertFalse(args.install) args = tai.build_parser().parse_args(["--install-telegram"]) self.assertTrue(args.install_telegram) self.assertEqual(args.prompt, []) args = tai.build_parser().parse_args(["--profile", "work", "--yes"]) self.assertEqual(args.profile, "work") self.assertTrue(args.yes) class FakePrincipal: def __init__(self): self.depth = 0 self.profile = "t" self.config = None self.store = mock.Mock() self.store.redact = lambda text, profile=None: text self.runner_override = None class OrchestrationTests(unittest.TestCase): def setUp(self): with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def tearDown(self): with tai.AGENTS_LOCK: tai.AGENTS.clear() tai.AGENTS_NEXT[0] = 1 def test_spawn_poll_instant(self): agent_id = tai.spawn_agent("do it", "t", 60, None, None, 0, lambda task, profile, timeout: "stub-done") self.assertEqual(agent_id, 1) status, text = tai.poll_agent(agent_id, wait=5) self.assertEqual(status, "done") self.assertIn("stub-done", text) def test_poll_tool_marks_collected(self): tools = tai.Tools(FakePrincipal()) first = tai.spawn_agent("quick", "t", 60, None, None, 0, lambda task, profile, timeout: "r") self.assertIn("[done]", tools.dispatch("poll", {"id": first, "wait": 5})) def broken(task, profile, timeout): raise RuntimeError("boom") bad = tai.spawn_agent("bad", "t", 60, None, None, 0, broken) self.assertIn("[error]", tools.dispatch("poll", {"id": bad, "wait": 5})) with tai.AGENTS_LOCK: self.assertTrue(tai.AGENTS[first]["collected"]) self.assertTrue(tai.AGENTS[bad]["collected"]) def test_poll_running_marks_nothing(self): tools = tai.Tools(FakePrincipal()) agent_id = tai.spawn_agent("slow", "t", 60, None, None, 0, lambda task, profile, timeout: time.sleep(2) or "slow-done") self.assertIn("[running]", tools.dispatch("poll", {"id": agent_id})) with tai.AGENTS_LOCK: self.assertFalse(tai.AGENTS[agent_id]["collected"]) def test_poll_wait_runs_lane_board(self): tools = tai.Tools(FakePrincipal()) agent_id = tai.spawn_agent("slow", "t", 60, None, None, 0, lambda task, profile, timeout: time.sleep(2) or "slow-done") with mock.patch.object(sys.stdout, "isatty", return_value=True): with mock.patch.dict(os.environ, {"NO_COLOR": ""}): with mock.patch("tai.LaneBoard") as board_cls: result = tools.dispatch("poll", {"id": agent_id, "wait": 1}) self.assertIn("[running]", result) board_cls.return_value.start.assert_called_once_with() board_cls.return_value.stop.assert_called_once_with() self.assertTrue(tools.board_shown) def test_poll_running_and_missing(self): def slow(task, profile, timeout): time.sleep(2) return "slow-done" agent_id = tai.spawn_agent("slow", "t", 60, None, None, 0, slow) status, text = tai.poll_agent(agent_id, wait=0) self.assertEqual(status, "running") self.assertIn("still running", text) status, _text = tai.poll_agent(999, wait=0) self.assertEqual(status, "missing") status, text = tai.poll_agent(agent_id, wait=5) self.assertEqual(status, "done") def test_timeout_and_error_status(self): slow_id = tai.spawn_agent("t", "t", 60, None, None, 0, lambda task, profile, timeout: "partial\n[time limit reached]") self.assertEqual(tai.poll_agent(slow_id, wait=5)[0], "timeout") def broken(task, profile, timeout): raise RuntimeError("boom") bad_id = tai.spawn_agent("t", "t", 60, None, None, 0, broken) status, text = tai.poll_agent(bad_id, wait=5) self.assertEqual(status, "error") self.assertIn("boom", text) def test_list_and_clear(self): tai.spawn_agent("one", "t", 60, None, None, 0, lambda task, profile, timeout: "r1") tai.spawn_agent("two", "t", 60, None, None, 0, lambda task, profile, timeout: time.sleep(2) or "r2") self.assertEqual(tai.poll_agent(1, wait=5)[0], "done") records = tai.list_agents() self.assertEqual(len(records), 2) self.assertEqual(tai.clear_agents(), 1) self.assertEqual(len(tai.list_agents()), 1) def test_fork_tool(self): app = FakePrincipal() app.runner_override = lambda task, profile, timeout: "forked-ok" tools = tai.Tools(app) started = tools.dispatch("fork", json.dumps({"task": "research x"})) self.assertIn("agent 1 started", started) result = tools.dispatch("poll", json.dumps({"id": 1, "wait": 5})) self.assertIn("forked-ok", result) def test_fork_depth_limit(self): app = FakePrincipal() app.depth = 2 tools = tai.Tools(app) self.assertIn("depth limit", tools.dispatch("fork", json.dumps({"task": "x"}))) def test_reasoning_only_becomes_result(self): with tempfile.TemporaryDirectory() as tmp: old_home = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp try: class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) store = tai.Store(config, tai.Seal(config.home, "")) agent = tai.Agent(config, store, persist=False, quiet=True) reply = {"role": "assistant", "content": "", "reasoning": "thought out", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", return_value=reply): self.assertEqual(agent.run_turn("hi", capture=True), "thought out") store.close() finally: if old_home is None: os.environ.pop("TAI_HOME", None) else: os.environ["TAI_HOME"] = old_home def test_deadline_shortcircuit(self): with tempfile.TemporaryDirectory() as tmp: old_home = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp try: class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) store = tai.Store(config, tai.Seal(config.home, "")) agent = tai.Agent(config, store, persist=False, quiet=True) agent.deadline = time.time() - 1 with mock.patch.object(agent.chat, "complete", side_effect=AssertionError("network used")): result = agent.run_turn("hi", capture=True) self.assertTrue(agent.timed_out) self.assertIn("time limit reached", result) store.close() finally: if old_home is None: os.environ.pop("TAI_HOME", None) else: os.environ["TAI_HOME"] = old_home class MarkdownTests(unittest.TestCase): def test_passthrough_without_tty(self): with mock.patch("sys.stdout") as fake_out: fake_out.isatty.return_value = False self.assertEqual(tai.render_markdown("**x**"), "**x**") with mock.patch("sys.stdout") as fake_out: fake_out.isatty.return_value = True with mock.patch.dict(os.environ, {"NO_COLOR": "1"}): self.assertEqual(tai.render_markdown("**x**"), "**x**") def test_headings(self): out = tai.render_markdown("# One\n\n## Two\n\n### Three", width=80, color=True) self.assertNotIn("#", out) self.assertIn(tai.Ansi.BOLD + tai.Ansi.CYAN + "One", out) self.assertIn(tai.Ansi.BOLD + "Two", out) self.assertIn("Three", tai.strip_ansi(out)) def test_inline_styles(self): out = tai.render_markdown("**b** *i* `c` ~~s~~ _u_ __w__", width=80, color=True) self.assertIn(tai.style_text("b", tai.Ansi.BOLD), out) self.assertIn(tai.style_text("i", tai.Ansi.DIM), out) self.assertIn(tai.style_text("c", tai.Ansi.CYAN), out) self.assertIn(tai.style_text("s", tai.Ansi.STRIKE), out) self.assertIn(tai.style_text("u", tai.Ansi.DIM), out) self.assertIn(tai.style_text("w", tai.Ansi.BOLD), out) self.assertNotIn("**", out) def test_snake_case_survives(self): out = tai.render_markdown("use my_var_name and `__init__` here", width=80, color=True) self.assertIn("my_var_name", tai.strip_ansi(out)) self.assertIn("__init__", tai.strip_ansi(out)) self.assertNotIn(tai.Ansi.BOLD, out) def test_link_and_image(self): out = tai.render_markdown("[docs](https://x.example/d) and ![alt](pic.png)", width=80, color=True) self.assertIn("docs", out) self.assertIn("https://x.example/d", out) self.assertIn("[image: alt]", tai.strip_ansi(out)) self.assertNotIn("[docs]", out) def test_code_fence_verbatim(self): out = tai.render_markdown("```python\nreturn \"**x**\"\n```", width=80, color=True) self.assertIn('"**x**"', out) self.assertNotIn(tai.Ansi.BOLD, out) self.assertNotIn("```", out) def test_unclosed_fence(self): out = tai.render_markdown("```\ncode **x**", width=80, color=True) self.assertIn("**x**", out) self.assertNotIn(tai.Ansi.BOLD, out) def test_table_alignment(self): source = "| item | qty |\n|---|---:|\n| apple | 12 |\n| fig | 3 |" out = tai.render_markdown(source, width=80, color=True) plain = tai.strip_ansi(out) self.assertIn("┌───────┬─────┐", plain) self.assertIn("│ apple │ 12 │", plain) self.assertIn("│ fig │ 3 │", plain) self.assertEqual({len(line) for line in plain.splitlines()}, {15}) self.assertIn(tai.Ansi.BOLD, out) def test_table_escaped_pipe(self): out = tai.render_markdown("| a |\n|---|\n| x\\|y |", width=80, color=True) plain = tai.strip_ansi(out) self.assertIn("x|y", plain) self.assertEqual(len(plain.splitlines()), 5) def test_lists(self): source = "- a\n - b\n- [x] done\n- [ ] open\n1. x\n1. y" plain = tai.strip_ansi(tai.render_markdown(source, width=80, color=True)) self.assertIn("• a", plain) self.assertIn(" ◦ b", plain) self.assertIn("☑ done", plain) self.assertIn("☐ open", plain) self.assertIn("1. x", plain) self.assertIn("2. y", plain) def test_quote_and_rule(self): out = tai.render_markdown("> wise\n> words\n\n---", width=40, color=True) plain = tai.strip_ansi(out) self.assertIn("│ wise words", plain) self.assertIn("─" * 40, plain) def test_wrap_keeps_styles(self): out = tai.render_markdown("**word " + " ".join("w%d" % n for n in range(20)) + " end**", width=30, color=True) lines = out.splitlines() self.assertGreater(len(lines), 1) self.assertNotIn("**", out) for line in lines: self.assertLessEqual(len(tai.strip_ansi(line)), 30) self.assertIn(tai.Ansi.BOLD, out) def test_escapes_and_breaks(self): out = tai.render_markdown("a \\*b\\* c
d", width=80, color=True) plain = tai.strip_ansi(out) self.assertIn("*b*", plain) self.assertNotIn(tai.Ansi.DIM, out) self.assertEqual(plain.splitlines(), ["a *b* c", "d"]) def test_run_turn_renders_reply(self): with tempfile.TemporaryDirectory() as tmp: old_home = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp try: class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) store = tai.Store(config, tai.Seal(config.home, "")) agent = tai.Agent(config, store) reply = {"role": "assistant", "content": "# Title\n\nHello **bold**", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", return_value=reply): with mock.patch.dict(os.environ): os.environ.pop("NO_COLOR", None) with mock.patch("sys.stdout") as fake_out: fake_out.isatty.return_value = True with mock.patch("builtins.print") as printer: with mock.patch("tai.Spinner"): result = agent.run_turn("hi") self.assertEqual(result, "# Title\n\nHello **bold**") printed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn(tai.Ansi.BOLD, printed) self.assertIn("Title", printed) self.assertNotIn("# Title", printed) store.close() finally: if old_home is None: os.environ.pop("TAI_HOME", None) else: os.environ["TAI_HOME"] = old_home def test_run_turn_prints_no_name_banner(self): with tempfile.TemporaryDirectory() as tmp: old_home = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp try: class FakeArgs: profile = "t" yes = True config = tai.Config(FakeArgs()) store = tai.Store(config, tai.Seal(config.home, "")) agent = tai.Agent(config, store) reply = {"role": "assistant", "content": "Hello there", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", return_value=reply): with mock.patch.dict(os.environ): os.environ.pop("NO_COLOR", None) with mock.patch("sys.stdout") as fake_out: fake_out.isatty.return_value = True with mock.patch("builtins.print") as printer: with mock.patch("tai.Spinner"): agent.run_turn("hi") lines = [] for call in printer.call_args_list: if call.args: lines.extend(str(call.args[0]).splitlines()) banners = [line for line in lines if tai.strip_ansi(line).strip() == "tai"] self.assertEqual(banners, []) store.close() finally: if old_home is None: os.environ.pop("TAI_HOME", None) else: os.environ["TAI_HOME"] = old_home class ToolArgumentsTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = True self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "args-test-1")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_tool_name_clean(self): self.assertEqual(tai.tool_name_clean("shell<|channel|>commentary"), "shell") self.assertEqual(tai.tool_name_clean(" read_file "), "read_file") self.assertEqual(tai.tool_name_clean(None), "") def test_tool_args_text(self): self.assertEqual(tai.tool_args_text(None), "{}") self.assertEqual(tai.tool_args_text(""), "{}") self.assertEqual(tai.tool_args_text("{}"), "{}") self.assertEqual(tai.tool_args_text({"limit": 50}), '{"limit": 50}') self.assertEqual(tai.tool_args_text([]), "{}") self.assertEqual(json.loads(tai.tool_args_text({"checks": ["os"]})), {"checks": ["os"]}) def test_muse_reply_coerces_dict_arguments(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=True) reply = agent.chat.muse_reply({"content": "", "tool_calls": [{"name": "get_current_terminal_content", "arguments": {"limit": 50}}]}, "opencode") self.assertEqual(reply["tool_calls"][0]["arguments"], '{"limit": 50}') def test_normalize_coerces_dict_arguments(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=True) message = {"content": "", "tool_calls": [{"id": "c1", "function": {"name": "sysinfo", "arguments": {"checks": ["os"]}}}]} reply = agent.chat.normalize(message, "x") self.assertEqual(reply["tool_calls"][0]["arguments"], '{"checks": ["os"]}') def test_show_call_dict_arguments(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=False) with mock.patch("builtins.print") as printer: agent.show_call({"id": "c1", "name": "get_current_terminal_content", "arguments": {"limit": 50}}) printed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) self.assertIn("get_current_terminal_content", printed) self.assertIn("limit", printed) def test_dispatch_dict_arguments(self): tools = tai.Tools(FakeApp()) subset = tools.dispatch("sysinfo", {"checks": ["python"]}) self.assertEqual(len(subset.splitlines()), 1) self.assertIn("unknown checks: nope", tools.dispatch("sysinfo", {"checks": ["nope"]})) self.assertIn("not valid JSON", tools.dispatch("sysinfo", ["nope"])) def test_action_signature_dict_arguments(self): self.assertEqual(tai.action_signature("sysinfo", {"checks": ["os"]}), 'sysinfo {"checks": ["os"]}') self.assertEqual(tai.action_signature("sysinfo", '{"checks": ["os"]}'), 'sysinfo {"checks": ["os"]}') def test_redact_non_string_passthrough(self): marker = {"limit": 50} self.assertIs(self.store.redact(marker), marker) def test_run_turn_dict_arguments(self): agent = tai.Agent(self.config, self.store, persist=False, quiet=True) call_reply = {"role": "assistant", "content": "", "reasoning": "", "tool_calls": [{"id": "c1", "name": "sysinfo", "arguments": {"checks": ["os"]}}], "backend": "x"} final_reply = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} with mock.patch.object(agent.chat, "complete", side_effect=[call_reply, final_reply]): self.assertEqual(agent.run_turn("check os", capture=True), "done") class OpencodeAgentTests(unittest.TestCase): def test_agent_is_not_plan(self): # "plan" makes opencode inject "Plan mode ACTIVE - READ-ONLY" into the # model, which then refuses to emit tai's write tool calls. self.assertNotEqual(tai.OPENCODE_AGENT, "plan") captured = {} def fake_run(argv, **kwargs): captured["argv"] = argv return mock.Mock(stdout='{"type":"text","part":{"text":"ok"}}\n', stderr="", returncode=0) with mock.patch.object(tai, "opencode_binary", return_value="opencode"), mock.patch.object(tai.subprocess, "run", side_effect=fake_run): self.assertEqual(tai.Opencode.opencode_run("m", "hi", 5), "ok") argv = captured["argv"] self.assertEqual(argv[argv.index("--agent") + 1], tai.OPENCODE_AGENT) class WorkerIdleTests(unittest.TestCase): def test_idle_worker_is_flagged(self): record = {"id": 1, "task": "t", "status": "done", "elapsed": 3, "tools": 0} self.assertIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) record["tools"] = 2 self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) record["tools"] = None self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) running = {"id": 1, "task": "t", "status": "running", "elapsed": 3, "tools": 0} self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(running))) def test_poll_notes_zero_tool_calls(self): agent_id = tai.spawn_agent("idle", "t", 60, None, None, 0, lambda task, profile, timeout: "I am in plan mode") tai.poll_agent(agent_id, wait=5) with tai.AGENTS_LOCK: tai.AGENTS[agent_id]["tools"] = 0 status, text = tai.poll_agent(agent_id) self.assertEqual(status, "done") self.assertIn("ran 0 tool calls", text) with tai.AGENTS_LOCK: tai.AGENTS[agent_id]["tools"] = 3 self.assertNotIn("0 tool calls", tai.poll_agent(agent_id)[1]) def test_tools_counts_dispatches(self): tools = tai.Tools.__new__(tai.Tools) tools.calls = 0 tools.handlers = {"echo": lambda args: "x"} tools.app = mock.Mock(store=None) tools.dispatch("echo", {}) tools.dispatch("nope", {}) self.assertEqual(tools.calls, 2) class OpencodeParseTests(unittest.TestCase): def test_single_answer(self): answer = {"content": "hi", "tool_calls": []} self.assertEqual(tai.opencode_parse(json.dumps(answer)), answer) def test_schema_echo_then_answer(self): answer = {"content": "Checking which text files were touched today.", "tool_calls": [{"name": "shell", "arguments": '{"command": "ls -lt"}'}, {"name": "audit", "arguments": '{"limit": 30}'}]} text = json.dumps(tai.muse_output_schema()) + " " + json.dumps(answer) parsed = tai.opencode_parse(text) self.assertEqual(parsed["content"], answer["content"]) self.assertEqual(len(parsed["tool_calls"]), 2) self.assertEqual(parsed["tool_calls"][0]["name"], "shell") def test_history_json_echo_then_answer(self): stale = {"content": "stale tool output", "tool_calls": []} answer = {"content": "fresh answer", "tool_calls": []} text = "TOOL c1:\n" + json.dumps(stale) + "\n\n" + json.dumps(answer) self.assertEqual(tai.opencode_parse(text)["content"], "fresh answer") def test_tools_blob_echo_then_answer(self): tools = [{"name": "shell", "parameters": {"command": {"type": "string"}}}, {"name": "audit", "parameters": {"limit": {"type": "integer"}}}] answer = {"content": "done", "tool_calls": []} text = "TOOLS:\n" + json.dumps(tools) + "\n\nAnswer with content text plus tool_calls.\n" + json.dumps(answer) parsed = tai.opencode_parse(text) self.assertEqual(parsed["content"], "done") self.assertEqual(parsed["tool_calls"], []) def test_answer_then_trailing_prose(self): answer = {"content": "done", "tool_calls": []} parsed = tai.opencode_parse(json.dumps(answer) + "\nHope that helps! } junk {") self.assertEqual(parsed["content"], "done") self.assertEqual(parsed["tool_calls"], []) def test_fenced_answer(self): answer = {"content": "hi", "tool_calls": []} self.assertEqual(tai.opencode_parse("```json\n" + json.dumps(answer) + "\n```"), answer) def test_fenced_answer_among_prose(self): answer = {"content": "hi", "tool_calls": []} text = "Sure, here it is:\n```json\n" + json.dumps(answer) + "\n```\nLet me know." self.assertEqual(tai.opencode_parse(text), answer) def test_plain_text_fallback(self): self.assertEqual(tai.opencode_parse("just some words"), {"content": "just some words", "tool_calls": []}) self.assertEqual(tai.opencode_parse(""), {"content": "", "tool_calls": []}) self.assertEqual(tai.opencode_parse(None), {"content": "", "tool_calls": []}) def test_braces_inside_content_string(self): answer = {"content": 'use {x} and {"d": 1} wisely', "tool_calls": []} parsed = tai.opencode_parse(json.dumps(answer)) self.assertEqual(parsed["content"], 'use {x} and {"d": 1} wisely') def test_pretty_printed_answer(self): answer = {"content": "hi", "tool_calls": [{"name": "shell", "arguments": '{"command": "ls"}'}]} parsed = tai.opencode_parse(json.dumps(answer, indent=2)) self.assertEqual(parsed["content"], "hi") self.assertEqual(len(parsed["tool_calls"]), 1) def test_arguments_as_object_preserved(self): answer = {"content": "checking", "tool_calls": [{"name": "shell", "arguments": {"command": "ls -lt"}}]} parsed = tai.opencode_parse(json.dumps(tai.muse_output_schema()) + " " + json.dumps(answer)) self.assertEqual(parsed["tool_calls"][0]["arguments"], {"command": "ls -lt"}) def test_many_leading_braces_still_finds_answer(self): answer = {"content": "found", "tool_calls": []} parsed = tai.opencode_parse("{{{not json, {{{also not, " * 40 + json.dumps(answer)) self.assertEqual(parsed["content"], "found") def test_opencode_turn_runs_tools_after_schema_echo(self): answer = {"content": "checking", "tool_calls": [{"name": "shell", "arguments": {"command": "ls -lt"}}]} text = json.dumps(tai.muse_output_schema()) + " " + json.dumps(answer) class FakeArgs: profile = "t" yes = True with tempfile.TemporaryDirectory() as tmp: os.environ["TAI_HOME"] = tmp try: chat = tai.ChatClient(tai.Config(FakeArgs())) with mock.patch.object(tai.Opencode, "opencode_run", return_value=text): reply = chat.opencode_turn([{"role": "user", "content": "hi"}], None, None, "opencode", "m") finally: os.environ.pop("TAI_HOME", None) self.assertEqual(len(reply["tool_calls"]), 1) self.assertEqual(reply["tool_calls"][0]["name"], "shell") self.assertEqual(json.loads(reply["tool_calls"][0]["arguments"]), {"command": "ls -lt"}) class CliJsonRepairTests(unittest.TestCase): def make_chat(self, tmp): class FakeArgs: profile = "t" yes = True os.environ["TAI_HOME"] = tmp return tai.ChatClient(tai.Config(FakeArgs())) def test_find_answer_none_on_prose(self): self.assertIsNone(tai.opencode_find_answer("just some words")) self.assertIsNone(tai.opencode_find_answer("")) answer = {"content": "hi", "tool_calls": []} self.assertEqual(tai.opencode_find_answer(json.dumps(answer)), answer) def test_prose_then_json_repairs_once(self): tools = [{"type": "function", "function": {"name": "shell"}}] fixed = {"content": "checking", "tool_calls": [{"name": "shell", "arguments": '{"command": "ls"}'}]} with tempfile.TemporaryDirectory() as tmp: try: chat = self.make_chat(tmp) with mock.patch.object(tai.Opencode, "opencode_run", side_effect=["Ik kijk even mee op je scherm.", json.dumps(fixed)]) as runner: reply = chat.opencode_turn([{"role": "user", "content": "hi"}], tools, None, "opencode", "m") self.assertEqual(runner.call_count, 2) self.assertIn("no JSON object", runner.call_args_list[1][0][1]) finally: os.environ.pop("TAI_HOME", None) self.assertEqual(len(reply["tool_calls"]), 1) self.assertEqual(reply["tool_calls"][0]["name"], "shell") def test_prose_twice_accepts_second_as_final(self): tools = [{"type": "function", "function": {"name": "shell"}}] with tempfile.TemporaryDirectory() as tmp: try: chat = self.make_chat(tmp) with mock.patch.object(tai.Opencode, "opencode_run", side_effect=["first prose", "second prose"]) as runner: reply = chat.opencode_turn([{"role": "user", "content": "hi"}], tools, None, "opencode", "m") self.assertEqual(runner.call_count, 2) finally: os.environ.pop("TAI_HOME", None) self.assertEqual(reply["content"], "second prose") self.assertEqual(reply["tool_calls"], []) def test_json_first_runs_no_repair(self): tools = [{"type": "function", "function": {"name": "shell"}}] answer = {"content": "checking", "tool_calls": [{"name": "shell", "arguments": '{"command": "ls"}'}]} with tempfile.TemporaryDirectory() as tmp: try: chat = self.make_chat(tmp) with mock.patch.object(tai.Opencode, "opencode_run", return_value=json.dumps(answer)) as runner: reply = chat.opencode_turn([{"role": "user", "content": "hi"}], tools, None, "opencode", "m") self.assertEqual(runner.call_count, 1) finally: os.environ.pop("TAI_HOME", None) self.assertEqual(len(reply["tool_calls"]), 1) def test_no_repair_without_tools(self): with tempfile.TemporaryDirectory() as tmp: try: chat = self.make_chat(tmp) with mock.patch.object(tai.Opencode, "opencode_run", return_value="just chatting") as runner: reply = chat.opencode_turn([{"role": "user", "content": "hi"}], None, None, "opencode", "m") self.assertEqual(runner.call_count, 1) finally: os.environ.pop("TAI_HOME", None) self.assertEqual(reply["content"], "just chatting") def test_failed_repair_keeps_first_answer(self): tools = [{"type": "function", "function": {"name": "shell"}}] with tempfile.TemporaryDirectory() as tmp: try: chat = self.make_chat(tmp) with mock.patch.object(tai.Opencode, "opencode_run", side_effect=["usable prose", tai.BackendError("boom")]) as runner: reply = chat.opencode_turn([{"role": "user", "content": "hi"}], tools, None, "opencode", "m") self.assertEqual(runner.call_count, 2) finally: os.environ.pop("TAI_HOME", None) self.assertEqual(reply["content"], "usable prose") def test_prompt_contract_states_turn_ending(self): self.assertIn("text without tool calls ends your turn", tai.DEFAULT_SYSTEM) rules = tai.opencode_prompt([{"role": "user", "content": "hi"}], []) self.assertIn("ends the turn at once", rules) self.assertIn("Never narrate a planned action", rules) class MuseArgvTests(unittest.TestCase): def argv(self, session_id=None): class FakeConfig: explicit_model = "" return tai.muse_argv(FakeConfig(), "s.json", "p.txt", session_id) def test_ephemeral_omits_workspace_switch(self): argv = self.argv(None) self.assertIn("--no-session-log", argv) self.assertNotIn("--allow-workspace-switch", argv) self.assertNotIn("--session-id", argv) self.assertIn("--disable-shell", argv) self.assertIn("--disable-write", argv) def test_session_keeps_workspace_switch(self): argv = self.argv("sess-1") self.assertIn("--allow-workspace-switch", argv) self.assertIn("--session-id", argv) self.assertIn("sess-1", argv) self.assertNotIn("--no-session-log", argv) class RetrySummaryTests(unittest.TestCase): def test_empty_errors(self): self.assertEqual(tai.ChatClient.summarize_errors([]), "no details") def test_actionable_error_leads_over_missing_cli_tail(self): errors = [ "openrouter/m1: HTTP 401: No auth credentials found", "pollinations/openai: HTTP 429: rate limited", "muse/muse: muse CLI not found (MUSE_BIN or PATH)", ] summary = tai.ChatClient.summarize_errors(errors) self.assertTrue(summary.startswith("openrouter/m1: HTTP 401"), summary) self.assertIn("1 missing CLI skipped", summary) self.assertNotIn("muse CLI not found", summary.split(";")[0]) def test_only_missing_cli_still_reported(self): summary = tai.ChatClient.summarize_errors(["muse/muse: muse CLI not found (MUSE_BIN or PATH)"]) self.assertIn("muse CLI not found", summary) self.assertNotIn("skipped", summary) def test_duplicates_collapsed_with_more_count(self): errors = ["a/m: boom"] * 5 + ["b/m: bust", "c/m: kaput"] summary = tai.ChatClient.summarize_errors(errors) self.assertIn("a/m: boom; b/m: bust", summary) self.assertIn("+1 more", summary) def test_width_cap(self): summary = tai.ChatClient.summarize_errors(["a/m: " + "x" * 500]) self.assertLessEqual(len(summary), 320) def test_complete_retry_line_leads_with_actionable_error(self): class FakeArgs: profile = "t" yes = True with tempfile.TemporaryDirectory() as tmp: os.environ["TAI_HOME"] = tmp try: chat = tai.ChatClient(tai.Config(FakeArgs())) cands = [ {"provider": "openrouter", "chat_url": "https://x/chat", "models_url": "https://x/models", "key": "", "model": "m1", "profile": ""}, {"provider": "muse", "chat_url": "muse://exec", "models_url": "muse://models", "key": "", "model": "muse", "profile": ""}, ] calls = [] def fake_try(cand, messages, tools, payload, stream_sink, ephemeral, errors): calls.append(cand["provider"]) if len(calls) > len(cands): return {"role": "assistant", "content": "ok", "reasoning": "", "tool_calls": [], "backend": cand["provider"]} if cand["provider"] == "muse": errors.append("muse/muse: muse CLI not found (MUSE_BIN or PATH)") else: errors.append("openrouter/m1: HTTP 401: No auth credentials found") return None with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \ mock.patch.object(tai.ChatClient, "all_candidates", return_value=cands), \ mock.patch.object(chat, "try_candidate", side_effect=fake_try), \ mock.patch("time.sleep") as sleeper: err = io.StringIO() with mock.patch("sys.stderr", err): reply = chat.complete([{"role": "user", "content": "hi"}]) self.assertEqual(reply["content"], "ok") self.assertEqual(len(sleeper.call_args_list), 1) finally: os.environ.pop("TAI_HOME", None) out = err.getvalue() self.assertIn("all 2 candidates across 2 backends failed (round 1)", out) self.assertIn("openrouter/m1: HTTP 401", out) self.assertNotIn("retrying in 2s: muse/muse", out) def test_complete_give_up_error_summarizes(self): class FakeArgs: profile = "t" yes = True with tempfile.TemporaryDirectory() as tmp: os.environ["TAI_HOME"] = tmp try: config = tai.Config(FakeArgs()) config.retry_seconds = 0.05 chat = tai.ChatClient(config) cands = [ {"provider": "openrouter", "chat_url": "https://x/chat", "models_url": "https://x/models", "key": "", "model": "m1", "profile": ""}, {"provider": "muse", "chat_url": "muse://exec", "models_url": "muse://models", "key": "", "model": "muse", "profile": ""}, ] def fake_try(cand, messages, tools, payload, stream_sink, ephemeral, errors): errors.append("%s/%s: %s" % (cand["provider"], cand["model"], "muse CLI not found (MUSE_BIN or PATH)" if cand["provider"] == "muse" else "HTTP 401: No auth")) return None with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \ mock.patch.object(chat, "try_candidate", side_effect=fake_try): with self.assertRaises(tai.BackendError) as raised: chat.complete([{"role": "user", "content": "hi"}]) finally: os.environ.pop("TAI_HOME", None) self.assertIn("after 1 rounds", str(raised.exception)) self.assertTrue(str(raised.exception).index("openrouter/m1") < str(raised.exception).index("missing CLI skipped"), str(raised.exception)) class TuiLayoutTests(unittest.TestCase): def make_app(self, rows=10, cols=20): app = tai.TuiApp() app.rows = rows app.cols = cols return app def test_rows_fr_split_exact(self): app = self.make_app(rows=9, cols=30) rows = tai.TuiRows(tai.TuiPanel(h="1fr"), tai.TuiPanel(h="1fr"), tai.TuiPanel(h="1fr"), x=0, y=0, w=30, h=9) root = tai.TuiPanel(flow=None) root.add(rows) app.mount(root) self.assertEqual([(child.ay, child.ah) for child in rows.children], [(0, 3), (3, 3), (6, 3)]) def test_rows_fr_largest_remainder(self): app = self.make_app(rows=10, cols=30) rows = tai.TuiRows(tai.TuiPanel(h="1fr"), tai.TuiPanel(h="1fr"), tai.TuiPanel(h="1fr"), x=0, y=0, w=30, h=10) root = tai.TuiPanel(flow=None) root.add(rows) app.mount(root) self.assertEqual([child.ah for child in rows.children], [4, 3, 3]) self.assertEqual(sum(child.ah for child in rows.children), 10) def test_dock_carves_then_fill(self): app = self.make_app(rows=10, cols=20) root = tai.TuiPanel(flow=None) top = tai.TuiPanel(dock="top", h=2) left = tai.TuiPanel(dock="left", w=5) fill = tai.TuiPanel(x=0, y=0, w="100%", h="100%") root.add(top, left, fill) app.mount(root) self.assertEqual((fill.ax, fill.ay, fill.aw, fill.ah), (5, 2, 15, 8)) def test_float_percent_center_negative(self): app = self.make_app(rows=10, cols=20) root = tai.TuiPanel(flow=None) centered = tai.TuiPanel(x="center", y=1, w="50%", h=4) neg = tai.TuiPanel(x=-5, y=0, w=4, h=1) root.add(centered, neg) app.mount(root) self.assertEqual((centered.ax, centered.ay, centered.aw, centered.ah), (5, 1, 10, 4)) self.assertEqual(neg.ax, 11) def test_rows_cross_auto_fills_cols_hugs(self): app = self.make_app(rows=10, cols=20) root = tai.TuiPanel(flow=None) rows = tai.TuiRows(tai.TuiText("hi"), x=0, y=0, w=20, h=5) cols = tai.TuiCols(tai.TuiText("hi"), x=0, y=5, w=20, h=5) root.add(rows, cols) app.mount(root) self.assertEqual(rows.children[0].aw, 20) self.assertEqual(cols.children[0].ah, 1) def test_border_chrome_shrinks_content(self): panel = tai.TuiPanel(border=True, padding=1, w=10, h=6) panel.arrange(0, 0, 10, 6, (0, 0, 10, 6)) self.assertEqual(panel._content_w(), 6) self.assertEqual(panel._content_h(), 2) painted = panel.paint() self.assertEqual(len(painted), 6) top = "".join(char for char, _style in tai.tui_flatten(painted[0])) self.assertEqual((top[0], top[-1], len(top)), ("┌", "┐", 10)) class TuiCompositorTests(unittest.TestCase): def test_z_occlusion_top_wins(self): app = tai.TuiApp() app.rows = 6 app.cols = 20 root = tai.TuiPanel(flow=None) root.add(tai.TuiText("base-base-base-base", x=0, y=0, w=20, h=6)) root.add(tai.TuiText("TOP", x=5, y=2, w=5, h=1, z=5)) app.mount(root) text = app.render_text() self.assertEqual(text.split("\n")[2][5:8], "TOP") self.assertEqual(text.split("\n")[0][:4], "base") def test_layer_band_beats_z(self): low = tai.TuiPanel(z=5) high = tai.TuiPanel(layer="dialog", z=5) self.assertGreater(high.effective_z(), low.effective_z()) def test_child_paints_above_high_z_parent(self): app = tai.TuiApp() app.rows = 4 app.cols = 12 root = tai.TuiPanel(flow=None) dialog = tai.TuiPanel(layer="dialog", x=0, y=0, w=12, h=4) dialog.add(tai.TuiText("inside", x=0, y=0, w=12, h=4)) root.add(dialog) app.mount(root) order = [node for node in app.compositor.flatten(root)] self.assertLess(order.index(dialog), order.index(dialog.children[0])) self.assertIn("inside", app.render_text()) def test_diff_second_frame_short(self): app = tai.TuiApp() app.rows = 6 app.cols = 20 root = tai.TuiPanel(flow=None) root.add(tai.TuiText("hello", x=0, y=0, w=20, h=6)) app.mount(root) frame = app.compositor.compose(root, 6, 20) first = app.compositor.render(frame) frame2 = app.compositor.compose(root, 6, 20) second = app.compositor.render(frame2) self.assertIn("hello", first) self.assertIn(tai.TUI_SYNC_START, second) self.assertLess(len(second), len(first)) def test_hit_topmost(self): app = tai.TuiApp() app.rows = 6 app.cols = 20 root = tai.TuiPanel(flow=None) bottom = tai.TuiPanel(x=0, y=0, w=20, h=6, id="bottom") top = tai.TuiPanel(x=5, y=2, w=5, h=2, z=5, id="top") root.add(bottom, top) app.mount(root) self.assertIs(app.hitmap.at(6, 3), top) self.assertIs(app.hitmap.at(1, 1), bottom) self.assertIs(app.hitmap.at(19, 5), bottom) class TuiLazyTests(unittest.TestCase): """The lazy pipeline (cached cells, front-to-back coverage, damage rects, persistent screen) must always show exactly what the original full compositor would, while doing almost no work.""" def tearDown(self): tai.tui_set_theme("dark") @staticmethod def spawn(base, name): child = FakeChatAgent() child.bot = name child.messages = [{"role": "system", "content": "sys"}] return child def make_chat(self, cols=100, rows=30, exchanges=12): app = tai.TuiApp() app.rows, app.cols = rows, cols app.verify = True agent = FakeChatAgent() for n in range(exchanges): agent.messages += [{"role": "user", "content": "q %d" % n}, {"role": "assistant", "content": "answer " + "word " * 40}] root, refs = tai.tui_build_chat(app, agent, spawn=self.spawn) app.mount(root) refs["replay"]() return app, refs @staticmethod def chars(grid): return [("".join(cell[0] if cell else " " for cell in row)).rstrip() for row in grid] def test_storm_matches_reference_and_the_terminal(self): """Seeded random storm of keys, wheel, clicks, resizes, themes, dialogs and bot switches. Every frame is checked against the reference compositor (app.verify), and the bytes actually emitted are replayed through the VT emulator and compared with a fresh render.""" import random rng = random.Random(20260412) app, refs = self.make_chat() vt = tai.TuiVtScreen(app.rows, app.cols) vt.feed(app.frame_bytes().encode("utf-8")) keys = ["a", "b", " ", "backspace", "enter", "left", "right", "up", "down", "tab", "backtab", "pageup", "pagedown", "home", "end", "f2", "f4", "escape", "/", "@"] dialogs = [] for step in range(400): op = rng.choice(("key", "key", "key", "key", "wheel", "click", "resize", "note", "status", "dialog", "close", "bot", "timers", "idle")) if op == "key": key = rng.choice(keys) app.dispatch(tai.TuiKey(key=key, char=key if len(key) == 1 else "")) elif op == "wheel": app.dispatch(tai.TuiScroll(x=rng.randrange(app.cols), y=rng.randrange(app.rows), direction=rng.choice(("up", "down")))) elif op == "click": app.dispatch(tai.TuiClick(x=rng.randrange(app.cols), y=rng.randrange(app.rows), button=0, pressed=True)) elif op == "resize": app.rows, app.cols = rng.randrange(10, 40), rng.randrange(30, 140) app.compositor.invalidate() app.dirty_layout() app.root.emit(tai.TuiResize(panel=app.root, rows=app.rows, cols=app.cols)) vt = tai.TuiVtScreen(app.rows, app.cols) elif op == "note": refs["show"]("bot" if rng.random() < 0.5 else "you", "note %d " % step + "x" * rng.randrange(1, 120), None) elif op == "status": refs["paint_status"]() elif op == "dialog" and len(dialogs) < 3: dialog = tai.TuiQuestion("Pick %d" % step, ["one", "two", "three"], on_done=lambda value: None, title="Q", w=rng.randrange(20, 50)) app.root.add(dialog) dialogs.append(dialog) elif op == "close" and dialogs: dialogs.pop().close() elif op == "bot": refs["select_bot"](rng.choice(refs["bots"])) elif op == "timers": app._fire_timers(time.time() + 5) vt.feed(app.frame_bytes().encode("utf-8")) if step % 25 == 0: want = app.compositor.compose_reference(app.root, app.rows, app.cols) self.assertEqual(self.chars(vt.grid), tai.TuiCompositor.render_text(want).split("\n"), "step %d op %s" % (step, op)) self.assertGreater(app.verified, 300) def test_demo_scene_matches_reference(self): app = tai.TuiApp() app.rows, app.cols = 24, 80 app.verify = True root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black")) base = tai.TuiRows(id="base", x=0, y=0, w="100%", h="100%", border=True, title="base") base.add(tai.TuiText("hello", id="t"), tai.TuiButton("go"), tai.TuiProgress(0.3)) root.add(base) root.add(tai.TuiPanel(x=10, y=5, w=30, h=8, z=3, border=True, rounded=True, title="over", style=tai.TuiStyle(bg="blue"))) app.mount(root) app.frame_bytes() text = base.find("t") for n in range(30): text.text = "hello %d" % n app.frame_bytes() root.children[1].x = 5 + n app.frame_bytes() root.children[1].z = n % 5 app.frame_bytes() def test_typing_repaints_one_panel(self): app, refs = self.make_chat() app.verify = False refs["box"].focus() app.frame_bytes() painted = app.compositor.stats["painted"] app.dispatch(tai.TuiKey(key="x", char="x")) out = app.frame_bytes() self.assertIn("x", out) self.assertEqual(app.compositor.stats["painted"] - painted, 1) def test_idle_frame_writes_nothing_and_paints_nothing(self): app, refs = self.make_chat() app.verify = False app.frame_bytes() stats = dict(app.compositor.stats) refs["paint_status"]() self.assertEqual(app.frame_bytes(), "") self.assertEqual(app.compositor.stats["painted"], stats["painted"]) self.assertEqual(app.compositor.stats["emitted"], stats["emitted"]) def test_covered_panels_are_never_painted(self): app = tai.TuiApp() app.rows, app.cols = 10, 40 app.verify = False painted = [] class Probe(tai.TuiPanel): def paint(self): painted.append(self) return super().paint() root = Probe(flow=None) under = Probe(x=0, y=0, w=40, h=10) over = Probe(x=0, y=0, w=40, h=10, z=1) root.add(under, over) app.mount(root) app.frame_bytes() self.assertEqual(painted, [over]) def test_theme_switch_keeps_cached_cells_correct(self): app, refs = self.make_chat() app.frame_bytes() app.set_theme("gruvbox") vt_bytes = app.frame_bytes() self.assertIn("48;2;40;40;40", vt_bytes) def test_style_key_cache_drops_on_write(self): style = tai.TuiStyle(fg="red") before = style.key() style.fg = "blue" self.assertNotEqual(style.key(), before) self.assertEqual(style.key()[0], "blue") def test_sgr_from_key_matches_sgr(self): for style in (tai.TuiStyle(), tai.TuiStyle(fg="role:accent", bg="#102030", bold=True), tai.TuiStyle(fg=(1, 2, 3), bg=200, dim=True, underline=True, reverse=True, strike=True), tai.TuiStyle(fg="bright_cyan", bg="black")): self.assertEqual(tai.TuiStyle.sgr_from_key(style.key()), style.sgr()) def test_measure_memo_never_outlives_a_pass(self): app, refs = self.make_chat() self.assertIsNone(tai.TuiPanel._measure_memo) refs["box"].lines = ["a", "b", "c"] app.dirty_layout() app.frame_bytes() self.assertIsNone(tai.TuiPanel._measure_memo) def test_hit_map_builds_on_first_click_only(self): app, refs = self.make_chat() app.frame_bytes() self.assertIsNotNone(app.hitmap.pending) hit = app.hitmap.at(1, 1) self.assertIsNone(app.hitmap.pending) self.assertIsNotNone(hit) def test_touches_from_threads_are_never_lost(self): """Turn threads touch panels while the loop composes. Verify is off mid-run (the reference would race the writers); after the writers join, one more frame must equal a from-scratch recompose.""" import threading app = tai.TuiApp() app.rows, app.cols = 12, 40 app.verify = False root = tai.TuiRows() labels = [tai.TuiText("-", id="l%d" % n) for n in range(8)] root.add(*labels) app.mount(root) app.frame_bytes() stop = threading.Event() def writer(seed): for n in range(400): labels[(seed + n) % 8].text = "w%d-%d" % (seed, n) threads = [threading.Thread(target=writer, args=(seed,)) for seed in range(4)] for thread in threads: thread.start() while any(thread.is_alive() for thread in threads): app.frame_bytes() for thread in threads: thread.join() app.verify = True app.frame_bytes() self.assertEqual(app.verified, 1) def test_typing_frame_budget_at_200x50(self): app = tai.TuiApp() app.rows, app.cols = 50, 200 app.verify = False agent = FakeChatAgent() for n in range(40): agent.messages += [{"role": "user", "content": "q %d" % n}, {"role": "assistant", "content": "answer " + "word " * 60}] root, refs = tai.tui_build_chat(app, agent) app.mount(root) refs["replay"]() refs["box"].focus() app.frame_bytes() started = time.perf_counter() for _ in range(50): app.dispatch(tai.TuiKey(key="a", char="a")) app.frame_bytes() per_frame = (time.perf_counter() - started) / 50 self.assertLess(per_frame, 0.005, "typing frame %.2fms (measured 0.08ms on the dev box)" % (per_frame * 1000)) def test_broken_widget_garbles_only_itself(self): app = tai.TuiApp() app.rows, app.cols = 6, 20 app.verify = False class Broken(tai.TuiPanel): def paint(self): raise RuntimeError("boom") root = tai.TuiPanel(flow=None) root.add(tai.TuiText("under", x=0, y=0, w=20, h=6), Broken(x=0, y=0, w=5, h=2, z=1)) app.mount(root) app.frame_bytes() self.assertIn("under", app.render_text()) class TuiObservedTests(unittest.TestCase): """State a panel keeps in a list, dict, set or TuiStyle is watched: an in-place mutation repaints the panel with no touch() call.""" class Board(tai.TuiPanel): def __init__(self, **kwargs): super().__init__(**kwargs) self.items = ["a", "b"] self.meta = {"rows": [[1]], "k": 1} self.tags = {1, 2} def content_rows(self): text = ",".join(str(item) for item in self.items) + str(self.meta["rows"][0]) + str(sorted(self.tags)) return [[tai.TuiSpan(text, self.style)]] def mount(self): app = tai.TuiApp() app.rows, app.cols = 6, 40 app.verify = True root = tai.TuiPanel(flow=None) board = self.Board(x=0, y=0, w=30, h=2) root.add(board) app.mount(root) app.frame_bytes() return app, board def assertTouches(self, app, board, action, label): app.frame_bytes() self.assertNotIn(board, app._damage) action(board) self.assertIn(board, app._damage, "%s did not touch the panel" % label) app.frame_bytes() def test_every_list_mutator_touches(self): import operator cases = { "append": lambda b: b.items.append("x"), "extend": lambda b: b.items.extend(["x", "y"]), "insert": lambda b: b.items.insert(0, "x"), "remove": lambda b: b.items.remove("a"), "pop": lambda b: b.items.pop(), "clear": lambda b: b.items.clear(), "sort": lambda b: b.items.sort(), "reverse": lambda b: b.items.reverse(), "iadd": lambda b: operator.iadd(b.items, ["x"]), "imul": lambda b: operator.imul(b.items, 2), "setitem": lambda b: b.items.__setitem__(0, "z"), "slice": lambda b: b.items.__setitem__(slice(0, 1), ["p", "q"]), "delitem": lambda b: b.items.__delitem__(0), } for label, action in cases.items(): app, board = self.mount() self.assertTouches(app, board, action, "list." + label) def test_every_dict_mutator_touches(self): import operator cases = { "setitem": lambda b: b.meta.__setitem__("n", 2), "update": lambda b: b.meta.update({"n": 2}), "setdefault": lambda b: b.meta.setdefault("n", 3), "pop": lambda b: b.meta.pop("k"), "popitem": lambda b: b.meta.popitem(), "clear": lambda b: b.meta.clear(), "delitem": lambda b: b.meta.__delitem__("k"), "ior": lambda b: operator.ior(b.meta, {"n": 1}), } for label, action in cases.items(): app, board = self.mount() self.assertTouches(app, board, action, "dict." + label) def test_every_set_mutator_touches(self): import operator cases = { "add": lambda b: b.tags.add(9), "discard": lambda b: b.tags.discard(1), "remove": lambda b: b.tags.remove(1), "pop": lambda b: b.tags.pop(), "clear": lambda b: b.tags.clear(), "update": lambda b: b.tags.update({7}), "difference_update": lambda b: b.tags.difference_update({1}), "intersection_update": lambda b: b.tags.intersection_update({1}), "symmetric_difference_update": lambda b: b.tags.symmetric_difference_update({1, 8}), "ior": lambda b: operator.ior(b.tags, {5}), "iand": lambda b: operator.iand(b.tags, {1}), "isub": lambda b: operator.isub(b.tags, {1}), "ixor": lambda b: operator.ixor(b.tags, {1, 6}), } for label, action in cases.items(): app, board = self.mount() self.assertTouches(app, board, action, "set." + label) def test_a_mutation_shows_on_screen_with_no_touch_call(self): app, board = self.mount() board.items.append("zed") self.assertIn("zed", app.frame_bytes()) board.meta["rows"][0].append(77) self.assertIn("77", app.frame_bytes()) board.tags.add(4242) self.assertIn("4242", app.frame_bytes()) def test_nested_containers_are_watched_to_the_depth_cap(self): app, board = self.mount() board.deep = {"a": [{"b": [1]}]} app.frame_bytes() self.assertTouches(app, board, lambda b: b.deep["a"][0]["b"].append(2), "nested") board.items.append([1]) self.assertTouches(app, board, lambda b: b.items[-1].append(2), "inserted") nest = [] node = nest for _depth in range(tai.TUI_OBSERVE_DEPTH + 3): node.append([]) node = node[0] board.nest = nest node = board.nest for _depth in range(tai.TUI_OBSERVE_DEPTH + 1): node = node[0] self.assertIs(type(node), list) def test_the_panel_keeps_a_copy_and_copies_travel_plain(self): import copy import json import pickle app, board = self.mount() source = [1, 2] board.items = source self.assertIsNot(board.items, source) self.assertEqual(board.items, [1, 2]) app.frame_bytes() source.append(3) self.assertNotIn(board, app._damage) self.assertIs(type(copy.deepcopy(board.items)), list) self.assertIs(type(copy.copy(board.items)), list) self.assertIs(type(pickle.loads(pickle.dumps(board.meta))), dict) self.assertEqual(json.dumps(board.meta), '{"rows": [[1]], "k": 1}') self.assertEqual(board.tags, {1, 2}) def test_a_container_moved_to_another_panel_belongs_to_the_new_one(self): app, first = self.mount() second = self.Board(x=0, y=3, w=30, h=2) first.parent.add(second) app.frame_bytes() second.items = first.items app.frame_bytes() second.items.append("q") self.assertIn(second, app._damage) self.assertNotIn(first, app._damage) self.assertNotIn("q", first.items) def test_private_attributes_and_unmounted_panels_are_left_alone(self): board = self.Board() board.items.append("x") board._cache = [] self.assertIs(type(board._cache), list) self.assertIsNone(board.app) def test_a_style_written_in_place_repaints_every_panel_using_it(self): app, first = self.mount() second = self.Board(x=0, y=3, w=30, h=2) first.parent.add(second) shared = tai.TuiStyle(fg="red") first.style = shared second.style = shared app.frame_bytes() shared.fg = "green" self.assertEqual({first, second}, {node for node in (first, second) if node in app._damage}) self.assertIn("32m", app.frame_bytes()) shared.fg = "green" self.assertNotIn(first, app._damage) def test_the_whole_app_repaints_correctly_after_container_mutations(self): """Random in-place mutations, never a touch(): every frame equals the reference compositor (verify is on).""" import random rng = random.Random(5) app, board = self.mount() for step in range(200): op = rng.choice(("append", "pop", "set", "tag", "meta", "sort")) if op == "append": board.items.append("i%d" % step) elif op == "pop" and board.items: board.items.pop() elif op == "set" and board.items: board.items[rng.randrange(len(board.items))] = "s%d" % step elif op == "tag": board.tags.symmetric_difference_update({rng.randrange(20)}) elif op == "meta": board.meta["rows"][0].append(step) elif op == "sort": board.items.sort() app.frame_bytes() self.assertGreater(app.verified, 150) class TuiLayoutTests(unittest.TestCase): """A layout pass arranges only the subtrees that asked, each from the nearest ancestor whose size does not follow its content, and the result is exactly what arranging the whole tree would give (app.verify checks that on every partial pass).""" class Probe(tai.TuiPanel): log = None def arrange(self, x, y, w, h, clip): if TuiLayoutTests.Probe.log is not None: TuiLayoutTests.Probe.log.append(self.id) super().arrange(x, y, w, h, clip) def setUp(self): Probe = self.Probe self.app = tai.TuiApp() self.app.rows, self.app.cols = 30, 90 self.app.verify = True self.root = Probe(id="root", flow=None) self.page = Probe(id="page", x=0, y=0, w="100%", h="100%", flow="cols") self.left = Probe(id="left", w=20, h="100%", flow="rows") self.right = Probe(id="right", w="1fr", h="100%", flow="rows") self.page.add(self.left, self.right) for n in range(3): self.left.add(tai.TuiText("l%d" % n, id="l%d" % n)) self.right.add(tai.TuiText("r%d" % n, id="r%d" % n)) self.root.add(self.page) self.app.mount(self.root) self.app.frame_bytes() def tearDown(self): self.Probe.log = None def arranged(self, action): self.app.frame_bytes() self.app.verify = False self.Probe.log = [] action() self.app.frame_bytes() seen, self.Probe.log = self.Probe.log, None return seen def test_a_child_added_inside_a_fixed_size_panel_arranges_only_that_panel(self): seen = self.arranged(lambda: self.left.add(tai.TuiText("more"))) self.assertIn("left", seen) self.assertNotIn("right", seen) self.assertNotIn("page", seen) self.assertNotIn("root", seen) def test_a_panels_own_size_change_arranges_its_parent(self): seen = self.arranged(lambda: setattr(self.left, "w", 30)) self.assertIn("page", seen) self.assertNotIn("root", seen) self.assertEqual(self.right.ax, 30) def test_growth_of_a_content_sized_panel_climbs_to_the_first_fixed_one(self): self.left.w = "auto" self.app.frame_bytes() seen = self.arranged(lambda: setattr(self.root.find("l0"), "text", "a much longer label than before")) self.assertIn("page", seen) self.assertNotIn("root", seen) self.assertEqual(self.right.ax, len("a much longer label than before")) def test_text_of_a_content_sized_panel_that_changes_size_relays_out(self): self.left.w = "auto" self.app.frame_bytes() before = self.right.ax self.root.find("l1").text = "x" * 40 self.app.frame_bytes() self.assertEqual(self.right.ax, 40) self.assertNotEqual(before, 40) def test_z_and_layer_changes_arrange_nothing(self): seen = self.arranged(lambda: setattr(self.left, "z", 4)) self.assertEqual(seen, []) seen = self.arranged(lambda: setattr(self.left, "layer", "overlay")) self.assertEqual(seen, []) def test_quiet_writes_arrange_nothing(self): seen = self.arranged(lambda: (setattr(self.left, "w", 20), setattr(self.right, "visible", True))) self.assertEqual(seen, []) def test_hiding_and_showing_a_panel_moves_its_neighbours(self): self.left.visible = False self.app.frame_bytes() self.assertEqual(self.right.ax, 0) self.left.visible = True self.app.frame_bytes() self.assertEqual(self.right.ax, 20) def test_removed_panels_leave_no_stale_cells(self): self.right.remove(self.root.find("r1")) self.app.frame_bytes() self.assertNotIn("r1", self.app.render_text()) def test_an_anonymous_dirty_layout_arranges_everything(self): seen = self.arranged(self.app.dirty_layout) self.assertIn("root", seen) def test_too_many_requests_fall_back_to_a_full_pass(self): def spam(): for n in range(tai.TuiApp.LAYOUT_STARTS + 5): self.left.add(tai.TuiText("n%d" % n)) seen = self.arranged(spam) self.assertIn("root", seen) def test_random_trees_partial_equals_full(self): """A seeded storm over random trees (every size spec, docks, floats, borders, padding, visibility, adds, removes, text growth). Every partial pass is checked against a full arrange and every frame against the reference compositor.""" import random for seed in range(6): rng = random.Random(seed) app = tai.TuiApp() app.rows, app.cols = 30, 90 app.verify = True root = tai.TuiPanel(flow=None, id="root") containers = [root] nodes = [] def spec(): return rng.choice(["auto", "auto", 3, 8, 14, "25%", "50%", "100%", "1fr", "2fr"]) def make(): kw = dict(w=spec(), h=spec()) if rng.random() < .25: kw["border"] = True if rng.random() < .15: kw["padding"] = rng.choice([1, (1, 2), 0]) if rng.random() < .15: kw.update(x=rng.choice([0, 3, "center", -2]), y=rng.choice([0, 2, "center"])) elif rng.random() < .12: kw["dock"] = rng.choice(["top", "bottom", "left", "right"]) if rng.random() < .5: return tai.TuiText("t" * rng.randrange(1, 30), **kw) return tai.TuiPanel(flow=rng.choice(["rows", "cols", None]), gap=rng.choice([0, 0, 1]), **kw) def adopt(node): rng.choice(containers).add(node) nodes.append(node) if not isinstance(node, tai.TuiText): containers.append(node) for _n in range(12): adopt(make()) app.mount(root) app.frame_bytes() values = { "w": spec, "h": spec, "padding": lambda: tai.TuiPanel.norm_padding(rng.choice([0, 1, (1, 2)])), "gap": lambda: rng.choice([0, 1, 2]), "border": lambda: rng.random() < .5, "x": lambda: rng.choice([None, 0, 5, "center"]), "y": lambda: rng.choice([None, 0, 3]), "flow": lambda: rng.choice(["rows", "cols", None]), "align": lambda: rng.choice(["left", "center", "right"]), "valign": lambda: rng.choice(["top", "center", "bottom"]), "dock": lambda: rng.choice([None, "top", "left"]), "offset": lambda: rng.choice([(0, 0), (1, 1), (-1, 2)])} for _step in range(150): if len(nodes) < 3: adopt(make()) op = rng.choice(["set", "set", "set", "text", "add", "remove", "visible", "z"]) node = rng.choice(nodes) if op == "set": name = rng.choice(sorted(values)) setattr(node, name, values[name]()) elif op == "text" and isinstance(node, tai.TuiText): node.text = "t" * rng.randrange(1, 40) + "\n" * rng.randrange(0, 3) elif op == "add": adopt(make()) elif op == "remove" and node.parent is not None: node.parent.remove(node) gone = {id(item) for item in node.walk()} nodes[:] = [item for item in nodes if id(item) not in gone] containers[:] = [item for item in containers if id(item) not in gone] elif op == "visible": node.visible = not node.visible elif op == "z": node.z = rng.randrange(0, 4) app.frame_bytes() self.assertGreater(app.layouts["partial"], 100) def test_a_diverging_partial_layout_is_caught(self): """The oracle works: a partial pass that skips a panel it should have arranged raises.""" original = self.app._layout_roots self.app._layout_roots = lambda starts: [] self.right.w = "3fr" self.left.w = 33 with self.assertRaises(tai.TuiError): self.app.frame_bytes() self.app._layout_roots = original class TuiScrollTests(unittest.TestCase): """Scrolling a log is a terminal scroll plus the rows that are new, and a log reuses the converted rows of lines it has already painted. The bytes are replayed through the VT emulator, so a wrong scroll cannot hide.""" @staticmethod def chars(grid): return [("".join(cell[0] if cell else " " for cell in row)).rstrip() for row in grid] def build(self, rows=24, cols=80, lines=120, border=False, padding=0, cls=None): app = tai.TuiApp() app.rows, app.cols = rows, cols app.verify = True root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black")) side = tai.TuiRows(dock="left", w=14) side.add(*[tai.TuiText("side %d" % n) for n in range(5)]) log = (cls or tai.TuiLog)(w="100%", h="100%", border=border, padding=padding) for n in range(lines): log.append("line %03d %s" % (n, "abcdefghij" * (n % 5)), tai.TuiStyle(fg="red" if n % 3 else "green")) root.add(side, log) app.mount(root) vt = tai.TuiVtScreen(rows, cols) vt.feed(app.frame_bytes().encode("utf-8")) return app, log, vt def same(self, app, vt): want = app.compositor.compose_reference(app.root, app.rows, app.cols) self.assertEqual(self.chars(vt.grid), tai.TuiCompositor.render_text(want).split("\n")) def test_wheel_scroll_is_a_terminal_scroll_and_stays_exact(self): import random rng = random.Random(11) for cls in (tai.TuiLog, tai.TuiChatLog): app, log, vt = self.build(cls=cls) for _step in range(120): app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction=rng.choice(("up", "down", "up")))) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) self.assertGreater(app.compositor.stats["scrolled"], 500, cls.__name__) def test_a_scroll_frame_writes_far_less_than_a_cell_rewrite(self): app, log, vt = self.build(rows=40, cols=120, lines=300) log.scroll_by(0, -3) app.frame_bytes() app.verify = False stats = dict(app.compositor.stats) log.scroll_by(0, -3) out = app.frame_bytes() emitted = app.compositor.stats["emitted"] - stats["emitted"] self.assertTrue("\x1bM" in out or "\x1bD" in out, "no terminal scroll was emitted") self.assertLess(emitted, 40 * 106 // 4, "a 3 line scroll rewrote %d cells" % emitted) def test_full_width_log_scrolls_without_repairing_strips(self): app = tai.TuiApp() app.rows, app.cols = 20, 60 app.verify = True root = tai.TuiPanel(flow=None) log = tai.TuiLog(x=0, y=0, w="100%", h="100%") for n in range(100): log.append("row %03d" % n) root.add(log) app.mount(root) vt = tai.TuiVtScreen(20, 60) vt.feed(app.frame_bytes().encode("utf-8")) for direction in ("up", "up", "down", "down", "up"): app.dispatch(tai.TuiScroll(x=1, y=1, direction=direction)) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) self.assertEqual(app.compositor.stats["scrolled"], 5 * 20) def test_bordered_and_padded_logs_scroll_exactly(self): for border, padding in ((True, 0), (False, 1), (True, (1, 2))): app, log, vt = self.build(border=border, padding=padding) for direction in ("up", "up", "down", "up", "down", "down"): app.dispatch(tai.TuiScroll(x=log.ax + 3, y=log.ay + 3, direction=direction)) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) def test_appending_while_pinned_scrolls(self): app, log, vt = self.build() app.verify = True for n in range(30): log.append("fresh %d" % n) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) self.assertGreater(app.compositor.stats["scrolled"], 0) def test_a_wrong_hint_is_ignored_and_a_broken_scroll_is_caught(self): app, log, vt = self.build() log.scroll_y = 10 vt.feed(app.frame_bytes().encode("utf-8")) before = app.compositor.stats["scrolled"] log.scroll_y = 10 log._shift = 7 log.touch() vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) self.assertEqual(app.compositor.stats["scrolled"], before) compositor_scroll = tai.TuiCompositor.scroll def broken(self, stack, shifts, fresh, cols, rows, out): believed = [list(row) for row in self.last] before = self.stats["scrolled"] compositor_scroll(self, stack, shifts, fresh, cols, rows, out) if self.stats["scrolled"] != before: self.last[:] = believed tai.TuiCompositor.scroll = broken try: app.verify = False with self.assertRaises(AssertionError): for _n in range(10): app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction="up")) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) finally: tai.TuiCompositor.scroll = compositor_scroll def test_random_scroll_storm_with_overlays_and_resizes(self): import random for seed in range(8): rng = random.Random(seed) app, log, vt = self.build(rows=rng.randrange(12, 40), cols=rng.randrange(40, 110), lines=rng.randrange(40, 200), border=rng.random() < .4, padding=rng.choice([0, 0, 1]), cls=rng.choice([tai.TuiLog, tai.TuiChatLog])) app.compositor.margins = seed % 2 == 1 dialogs = [] for step in range(120): op = rng.choice(("wheel", "wheel", "wheel", "append", "page", "dialog", "close", "resize", "scrollx")) if op == "wheel": app.dispatch(tai.TuiScroll(x=log.ax + 1, y=log.ay + 1, direction=rng.choice(("up", "down")))) elif op == "append": log.append("new %d %s" % (step, "y" * rng.randrange(0, 60))) elif op == "page": log.scroll_by(0, rng.choice((-30, -7, -1, 1, 5, 40))) elif op == "scrollx": log.scroll_x = rng.choice((0, 0, 2, 5)) elif op == "dialog" and len(dialogs) < 2: dialog = tai.TuiQuestion("Pick", ["one", "two"], on_done=lambda value: None, title="Q", w=rng.randrange(20, 36)) app.root.add(dialog) dialogs.append(dialog) elif op == "close" and dialogs: dialogs.pop().close() elif op == "resize": app.rows, app.cols = rng.randrange(12, 40), rng.randrange(40, 110) app.compositor.invalidate() app.dirty_layout() vt = tai.TuiVtScreen(app.rows, app.cols) vt.feed(app.frame_bytes().encode("utf-8")) if step % 5 == 0: self.same(app, vt) def test_log_cells_equal_the_span_paint(self): """The reused-row path must give the cells the plain paint gives, for every border, padding, scroll and width, memo cold and warm.""" import random rng = random.Random(3) for cls in (tai.TuiLog, tai.TuiChatLog): for _case in range(40): app = tai.TuiApp() app.rows, app.cols = 20, 70 root = tai.TuiPanel(flow=None) log = cls(x=rng.randrange(0, 5), y=rng.randrange(0, 3), w=rng.randrange(1, 60), h=rng.randrange(1, 18), border=rng.random() < .4, padding=rng.choice([0, 1, (1, 2), (0, 1, 2, 0)]), style=tai.TuiStyle(bg=rng.choice([None, "blue"]))) for n in range(rng.randrange(0, 60)): log.append("line %d %s" % (n, "é" * rng.randrange(0, 40)), tai.TuiStyle(fg=rng.choice(["red", "green"]))) root.add(log) app.mount(root) app.frame_bytes() log.scroll_x = rng.choice((0, 0, 3)) log.scroll_y = rng.randrange(0, 30) log.scroll_y = log.scroll_y compositor = app.compositor for _pass in range(2): want = compositor.cells_from(log.paint(), log.aw) self.assertEqual(log.paint_cells(compositor), want, "%s border=%s padding=%s" % (cls.__name__, log.border, log.padding)) def test_a_scroll_converts_only_new_lines(self): app, log, vt = self.build(lines=400) app.verify = False log.scroll_by(0, -2) app.frame_bytes() compositor = app.compositor converted = [] original = compositor.cells_from def counting(painted, width): converted.append(len(painted)) return original(painted, width) compositor.cells_from = counting log.scroll_by(0, -3) app.frame_bytes() self.assertEqual(sum(converted), 3) def test_the_memo_is_capped(self): app, log, vt = self.build(lines=tai.TUI_ROW_MEMO + 300, rows=30) app.verify = False for pos in range(0, tai.TUI_ROW_MEMO + 300, 20): log.scroll_y = pos app.frame_bytes() self.assertLessEqual(len(log._row_memo), tai.TUI_ROW_MEMO) class TuiMarginTests(unittest.TestCase): """Left/right margins (DECLRMM): a terminal that answers the probe scrolls only the log's columns, so nothing beside it is touched or repaired.""" chars = staticmethod(TuiScrollTests.chars) build = TuiScrollTests.build same = TuiScrollTests.same def margined(self, **kwargs): app, log, vt = self.build(**kwargs) app.compositor.margins = True return app, log, vt def test_the_virtual_terminal_scrolls_inside_margins_only(self): vt = tai.TuiVtScreen(6, 10) for row in range(6): vt.feed(("\x1b[%d;1H%s" % (row + 1, str(row) * 10)).encode("ascii")) vt.feed(b"\x1b[?69h\x1b[3;7s\x1b[2;4r\x1b[4;3H\x1bD\x1b[?69l\x1b[r") self.assertEqual(vt.grid[1][0][0], "1") self.assertEqual(vt.grid[1][1][0], "1") self.assertEqual(vt.grid[1][2][0], "2") self.assertEqual(vt.grid[2][2][0], "3") self.assertEqual(vt.grid[3][2][0], " ") self.assertEqual(vt.grid[3][7][0], "3") self.assertEqual(vt.grid[1][8][0], "1") def test_the_virtual_terminal_answers_the_mode_query(self): vt = tai.TuiVtScreen(4, 10) vt.feed(b"\x1b[?69$p") self.assertEqual(vt.take_replies(), b"\x1b[?69;2$y") vt.feed(b"\x1b[?69h\x1b[?69$p\x1b[?7777$p") self.assertEqual(vt.take_replies(), b"\x1b[?69;1$y\x1b[?7777;0$y") def test_the_reply_to_the_probe_turns_margins_on_and_nothing_else_does(self): for text, want in (("\x1b[?69;2$y", True), ("\x1b[?69;1$y", True), ("\x1b[?69;0$y", False), ("\x1b[?69;4$y", False), ("\x1b[?25;1$y", False)): app = tai.TuiApp() app.rows, app.cols = 10, 20 app.mount(tai.TuiPanel(flow=None)) for event in app.input.feed(text.encode("ascii")): app.dispatch(event) self.assertIs(app.compositor.margins, want, text) def test_margins_keep_the_strips_out_of_the_scroll_and_stay_exact(self): import random rng = random.Random(5) for cls in (tai.TuiLog, tai.TuiChatLog): app, log, vt = self.margined(cls=cls) for _step in range(100): app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction=rng.choice(("up", "down", "up")))) out = app.frame_bytes() vt.feed(out.encode("utf-8")) self.same(app, vt) self.assertGreater(app.compositor.stats["scrolled"], 500, cls.__name__) def test_margins_write_less_than_repairing_the_strips(self): written = {} for margins in (False, True): app, log, vt = self.build(rows=24, cols=80, lines=300) app.compositor.margins = margins log.scroll_by(0, -3) app.frame_bytes() app.verify = False log.scroll_by(0, -3) out = app.frame_bytes() vt.feed(out.encode("utf-8")) written[margins] = len(out) self.assertEqual("\x1b[?69h" in out, margins) self.assertLess(written[True], written[False]) def test_a_narrow_screen_that_was_a_wash_now_scrolls(self): app, log, vt = self.margined(rows=24, cols=80, lines=300) log.scroll_by(0, -3) app.frame_bytes() app.verify = False log.scroll_by(0, -3) out = app.frame_bytes() self.assertIn("\x1b[?69h", out) self.assertIn("\x1b[?69l", out) def test_a_scroll_that_misreports_the_margin_model_is_caught(self): app, log, vt = self.margined() compositor_scroll = tai.TuiCompositor.scroll def broken(self, stack, shifts, fresh, cols, rows, out): believed = [list(row) for row in self.last] before = self.stats["scrolled"] compositor_scroll(self, stack, shifts, fresh, cols, rows, out) if self.stats["scrolled"] != before: self.last[:] = believed tai.TuiCompositor.scroll = broken try: app.verify = False with self.assertRaises(AssertionError): for _n in range(10): app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction="up")) vt.feed(app.frame_bytes().encode("utf-8")) self.same(app, vt) finally: tai.TuiCompositor.scroll = compositor_scroll class TuiClosedGapsTests(unittest.TestCase): """The gaps v1.35 listed as open: geometry animation, the idle heartbeat, watched user state, partial layout through all-auto trees, terminal row reuse and the scroll cost model.""" def app(self, rows=12, cols=50): app = tai.TuiApp() app.rows, app.cols = rows, cols app.verify = True return app def run_animation(self, app, panel, prop, to): anim = panel.animate(prop, to, duration=1.0, easing="linear") now = 100.0 app.tick_animations(now) values = [] for step in range(1, 11): app.tick_animations(now + step / 10.0) app.frame_bytes() values.append(getattr(panel, prop)) return anim, values def test_animating_x_y_w_h_moves_and_sizes_the_panel(self): app = self.app() root = tai.TuiPanel(flow=None) box = tai.TuiPanel(x="50%", y="center", w="auto", h=2, border=True) box.add(tai.TuiText("hello world")) root.add(box) app.mount(root) app.frame_bytes() start_x, start_y = box.ax, box.ay self.assertGreater(start_x, 0) _anim, values = self.run_animation(app, box, "x", 4) self.assertEqual(values[-1], 4) self.assertTrue(all(isinstance(value, int) for value in values)) self.assertEqual(values, sorted(values, reverse=True)) self.assertEqual(box.ax, 4) _anim, values = self.run_animation(app, box, "y", 1) self.assertEqual((box.ay, values[-1]), (1, 1)) _anim, values = self.run_animation(app, box, "h", 6) self.assertEqual((box.ah - 2, values[-1]), (6, 6)) _anim, values = self.run_animation(app, box, "w", 20) self.assertEqual((box.aw - 2, values[-1]), (20, 20)) def test_a_negative_offset_and_a_percent_start_from_where_they_are_drawn(self): app = self.app(10, 40) root = tai.TuiPanel(flow=None) box = tai.TuiPanel(x=-12, y=0, w=10, h=3) root.add(box) app.mount(root) app.frame_bytes() self.assertEqual(box.anim_start("x"), 18.0) self.assertEqual(box.anim_start("w"), 10.0) self.assertEqual(box.anim_start("scroll_y"), 0.0) pct = tai.TuiPanel(x=0, y=0, w="25%", h="50%") root.add(pct) app.frame_bytes() self.assertEqual((pct.anim_start("w"), pct.anim_start("h")), (10.0, 5.0)) def test_no_timer_means_the_loop_sleeps_until_woken(self): app = self.app() app.mount(tai.TuiPanel(flow=None)) app.frame_bytes() self.assertIsNone(app._idle_delay(1 / 30, time.time())) handle = app.after(5.0, lambda: None) delay = app._idle_delay(1 / 30, time.time()) self.assertTrue(0 < delay <= 5.0, delay) self.assertFalse(hasattr(tai, "TUI_IDLE_MAX")) def test_a_child_exit_signal_wakes_the_loop(self): import signal import threading import asyncio app = self.app() app.mount(tai.TuiPanel(flow=None)) stdin_r, stdin_w = os.pipe() out_r, out_w = os.pipe() app._stdin_fd, app._stdout_fd = stdin_r, out_w seen = [] app._old_chld = lambda signum, frame: seen.append(signum) def driver(): time.sleep(0.2) before = app.iterations app._on_chld(signal.SIGCHLD, None) time.sleep(0.2) seen.append(app.iterations - before) app.quit() thread = threading.Thread(target=driver, daemon=True) thread.start() try: asyncio.run(app._main()) finally: thread.join(timeout=5) for fd in (stdin_r, stdin_w, out_r, out_w): os.close(fd) self.assertEqual(seen[0], signal.SIGCHLD) self.assertGreaterEqual(seen[1], 1) class Model(tai.TuiWatched): def __init__(self): self.title = "a" self.count = 1 self._cache = 0 class Card(tai.TuiPanel): def __init__(self, **kwargs): super().__init__(**kwargs) self.model = TuiClosedGapsTests.Model() self.models = [TuiClosedGapsTests.Model()] self.by_name = {"k": TuiClosedGapsTests.Model()} def content_rows(self): return [[tai.TuiSpan("%s %d" % (self.model.title, self.model.count), self.style)]] def card(self): app = self.app(6, 30) root = tai.TuiPanel(flow=None) card = self.Card(x=0, y=0, w=20, h=2) root.add(card) app.mount(root) app.frame_bytes() return app, card def test_a_field_of_a_watched_object_touches_every_panel_that_holds_it(self): for label, target in (("attribute", lambda c: c.model), ("list item", lambda c: c.models[0]), ("dict value", lambda c: c.by_name["k"])): app, card = self.card() app.frame_bytes() self.assertNotIn(card, app._damage) target(card).title = "b" self.assertIn(card, app._damage, label) app.frame_bytes() target(card).title = "b" self.assertNotIn(card, app._damage, label + " equal write") target(card)._cache = 5 self.assertNotIn(card, app._damage, label + " private write") def test_a_watched_object_inserted_later_is_adopted(self): app, card = self.card() late = self.Model() card.models.append(late) card.by_name["late"] = self.Model() card.models.extend([self.Model()]) for item in (late, card.by_name["late"], card.models[-1]): app.frame_bytes() self.assertNotIn(card, app._damage) item.count = 50 self.assertIn(card, app._damage) def test_a_watched_object_shown_by_two_panels_touches_both_and_a_copy_is_free(self): import copy import pickle app = self.app(6, 30) root = tai.TuiPanel(flow=None) one = self.Card(x=0, y=0, w=10, h=1) two = self.Card(x=0, y=2, w=10, h=1) root.add(one, two) app.mount(root) app.frame_bytes() shared = self.Model() one.model = shared two.model = shared app.frame_bytes() shared.count = 9 self.assertIn(one, app._damage) self.assertIn(two, app._damage) for clone in (copy.copy(shared), pickle.loads(pickle.dumps(shared)), copy.deepcopy(shared)): app.frame_bytes() clone.count = 77 self.assertEqual(app._damage, set()) self.assertEqual(shared.count, 9) def test_spans_and_styles_are_watched_objects(self): app, card = self.card() self.assertTrue(issubclass(tai.TuiSpan, tai.TuiWatched)) self.assertTrue(issubclass(tai.TuiStyle, tai.TuiWatched)) card.spans = [tai.TuiSpan("x")] app.frame_bytes() card.spans[0].text = "y" self.assertIn(card, app._damage) def test_a_partial_layout_through_an_all_auto_tree_arranges_only_the_path(self): app = self.app(40, 100) root = tai.TuiRows(w="auto", h="auto") texts = [] for branch in range(12): column = tai.TuiRows(w="auto", h="auto") inner = tai.TuiRows(w="auto", h="auto") for leaf in range(6): text = tai.TuiText("leaf %d %d" % (branch, leaf), w="auto", h="auto") inner.add(text) texts.append(text) column.add(inner) root.add(column) app.mount(root) app.frame_bytes() total = len(list(root.walk())) before = dict(app.layouts) texts[40].text = "a much longer leaf than before, to grow its row" app.frame_bytes() self.assertEqual(app.layouts["full"], before["full"]) arranged = app.layouts["arranged"] - before["arranged"] self.assertGreater(arranged, 0) self.assertLess(arranged, total // 4, "arranged %d of %d panels" % (arranged, total)) def test_a_change_made_under_a_hidden_branch_is_applied_when_it_shows(self): app = self.app(20, 60) root = tai.TuiRows() branch = tai.TuiRows(id="branch") inner = tai.TuiRows() leaf = tai.TuiText("deep", offset=(0, 0)) inner.add(leaf) branch.add(inner) root.add(tai.TuiText("above"), branch, tai.TuiText("below")) app.mount(root) app.frame_bytes() branch.visible = False app.frame_bytes() leaf.offset = (3, 1) app.frame_bytes() branch.visible = True app.frame_bytes() self.assertEqual((leaf.ax - inner.ax, leaf.ay - inner.ay), (3, 1)) def test_a_scroll_offset_written_past_the_end_is_clamped_by_the_next_frame(self): app = self.app(10, 40) root = tai.TuiPanel(flow=None) log = tai.TuiLog(x=0, y=0, w=30, h=5) for n in range(8): log.append("row %d" % n) root.add(log) app.mount(root) app.frame_bytes() log.scroll_y = 500 app.frame_bytes() self.assertEqual(log.scroll_y, 3) log.scroll_x = 7 app.frame_bytes() self.assertEqual(log.scroll_x, 0) before = app.layouts["partial"] + app.layouts["full"] log.scroll_y = 1 app.frame_bytes() self.assertEqual(app.layouts["partial"] + app.layouts["full"], before, "an in-range scroll asked for a layout") def test_a_terminal_reuses_the_rows_that_did_not_change(self): app = self.app(12, 40) root = tai.TuiPanel(flow=None) term = tai.TuiTerminal(x=0, y=0, w=30, h=8, border=False) root.add(term) app.mount(root) term.screen.resize(8, 30) for n in range(8): term.screen.feed(("\x1b[3%dmline %d\r\n" % (n % 7, n)).encode()) first = term.content_rows() term.screen.feed(b"\x1b[8;1H\x1b[2Kchanged") second = term.content_rows() same = sum(1 for a, b in zip(first, second) if a is b) self.assertGreaterEqual(same, 6) self.assertIsNot(first[7], second[7]) self.assertEqual("".join(span.text for span in second[7]).rstrip(), "changed") for _n in range(tai.TUI_ROW_MEMO + 10): term.screen.feed(("\x1b[1;1H%d" % _n).encode()) term.content_rows() self.assertLessEqual(len(term._row_memo), tai.TUI_ROW_MEMO + 1) def test_a_terminal_cursor_row_is_not_confused_with_the_same_row_elsewhere(self): app = self.app(12, 40) root = tai.TuiPanel(flow=None) term = tai.TuiTerminal(x=0, y=0, w=30, h=4, border=False) root.add(term) app.mount(root) term.screen.resize(4, 30) term.focus() term.screen.feed(b"\x1b[1;1Habc\x1b[2;1Habc\x1b[1;1H") rows = term.content_rows() self.assertEqual(sum(1 for span in rows[0] if span.style.reverse), 1) self.assertEqual(sum(1 for span in rows[1] if span.style.reverse), 0) def test_the_scroll_cost_counts_runs_and_style_switches_like_the_diff_does(self): red, blue = ("r", "red"), ("b", "blue") a = [("x", 1)] * 10 self.assertEqual(tai.TuiCompositor.cost([(a, a)]), 0) one_run = [("y", 1)] * 3 + [("x", 1)] * 7 two_runs = [("y", 1)] * 2 + [("x", 1)] + [("y", 1)] * 2 + [("x", 1)] * 5 switched = [("y", 1)] * 3 + [("y", 2)] * 2 + [("x", 1)] * 5 base = tai.TuiCompositor.cost([(one_run, a)]) self.assertEqual(base, 3 + tai.TUI_MOVE_COST + tai.TUI_SWITCH_COST) self.assertEqual(tai.TuiCompositor.cost([(two_runs, a)]), 4 + 2 * tai.TUI_MOVE_COST + tai.TUI_SWITCH_COST) self.assertEqual(tai.TuiCompositor.cost([(switched, a)]), 5 + tai.TUI_MOVE_COST + 2 * tai.TUI_SWITCH_COST) class TuiLoopTests(unittest.TestCase): """The main loop sleeps until something is due. These run the real _main on pipes (a stand-in for the terminal) and measure wakeups and latency.""" def drive(self, app, driver, hold=0.6, finish=True): """Run app._main with stdin and stdout on pipes; driver(write_fd, frames) runs on a thread, then the app quits (unless finish is False: the driver must end the loop itself). Returns the list of (time, bytes) frames.""" import asyncio import threading stdin_r, stdin_w = os.pipe() out_r, out_w = os.pipe() os.set_blocking(out_r, False) app._stdin_fd = stdin_r app._stdout_fd = out_w frames = [] original = app.frame_bytes def counted(): data = original() if data: frames.append((time.time(), len(data))) return data app.frame_bytes = counted def run_driver(): time.sleep(0.15) driver(stdin_w, frames) time.sleep(hold) if finish: app.quit() app.wake() thread = threading.Thread(target=run_driver, daemon=True) thread.start() try: asyncio.run(app._main()) finally: thread.join(timeout=5) for fd in (stdin_r, stdin_w, out_r, out_w): os.close(fd) return frames def scene(self): app = tai.TuiApp() app.rows, app.cols = 10, 40 app.verify = True root = tai.TuiRows() label = tai.TuiText("-", id="label") box = tai.TuiTextBox(id="box", focusable=True) root.add(label, box) app.mount(root) return app, label, box def test_an_idle_screen_wakes_almost_never(self): app, label, box = self.scene() frames = self.drive(app, lambda w, f: time.sleep(1.2), hold=0.0) self.assertLessEqual(app.iterations, 4, "an idle loop ran %d times in 1.3 s" % app.iterations) self.assertEqual(len(frames), 1) def test_a_key_reaches_the_screen_in_milliseconds(self): app, label, box = self.scene() latency = [] def driver(write_fd, frames): for char in b"abc": before = len(frames) started = time.time() os.write(write_fd, bytes([char])) while len(frames) == before and time.time() - started < 1.0: time.sleep(0.0005) latency.append(frames[-1][0] - started if len(frames) > before else 9.9) time.sleep(0.1) self.drive(app, driver, hold=0.1) self.assertEqual(box.value, "abc") self.assertLess(max(latency), 0.05, latency) def test_a_touch_from_another_thread_wakes_the_loop(self): app, label, box = self.scene() latency = [] def driver(write_fd, frames): for n in range(3): before = len(frames) started = time.time() label.text = "thread %d" % n while len(frames) == before and time.time() - started < 1.0: time.sleep(0.0005) latency.append(frames[-1][0] - started if len(frames) > before else 9.9) time.sleep(0.2) self.drive(app, driver, hold=0.1) self.assertLess(max(latency), 0.08, latency) def test_timers_fire_on_time_without_polling(self): app, label, box = self.scene() fired = [] started = [] def driver(write_fd, frames): started.append(time.time()) app.after(0.3, lambda: fired.append(time.time())) app.after(0.45, lambda: setattr(label, "text", "timer")) time.sleep(0.7) frames = self.drive(app, driver, hold=0.05) self.assertEqual(len(fired), 1) self.assertLess(abs(fired[0] - started[0] - 0.3), 0.06) self.assertEqual(label.text, "timer") self.assertLessEqual(app.iterations, 8) def test_animation_runs_at_the_frame_rate_then_stops(self): app, label, box = self.scene() mover = tai.TuiProgress(0.0, w=20) app.root.add(mover) def driver(write_fd, frames): mover.animate("value", 1.0, duration=0.4) time.sleep(0.5) frames = self.drive(app, driver, hold=0.5) self.assertEqual(mover.value, 1.0) self.assertGreaterEqual(len(frames), 8) self.assertLessEqual(app.iterations, 8 + int(0.4 * app.fps) + 6) def test_half_a_key_sequence_is_flushed_as_escape(self): app, label, box = self.scene() seen = [] app.root.on_key = lambda event: seen.append(event.key) def driver(write_fd, frames): os.write(write_fd, b"\x1b") time.sleep(0.2) self.drive(app, driver, hold=0.0) self.assertIn("escape", seen) def test_a_resize_signal_wakes_the_loop(self): app, label, box = self.scene() app.rows = 5 app.cols = 20 def driver(write_fd, frames): app._on_winch(None, None) time.sleep(0.2) self.drive(app, driver, hold=0.0) self.assertEqual((app.rows, app.cols), (24, 80)) def test_ticking_panels_run_every_frame_and_only_then(self): app, label, box = self.scene() ticks = [] class Ticker(tai.TuiPanel): def on_tick(self, event): ticks.append(event.now) self.drive(app, lambda w, f: time.sleep(0.4), hold=0.0) self.assertEqual(ticks, []) app2, label2, box2 = self.scene() app2.root.add(Ticker(w=1, h=1)) self.drive(app2, lambda w, f: time.sleep(0.4), hold=0.0) self.assertGreaterEqual(len(ticks), 8) def test_ctrl_c_and_end_of_input_end_the_loop_at_once(self): for ending in (b"\x03", None): app, label, box = self.scene() marks = [] def driver(write_fd, frames): marks.append(time.time()) if ending is None: os.close(write_fd) else: os.write(write_fd, ending) time.sleep(2.0) import asyncio import threading stdin_r, stdin_w = os.pipe() out_r, out_w = os.pipe() app._stdin_fd = stdin_r app._stdout_fd = out_w def run_driver(): time.sleep(0.15) driver(stdin_w, []) thread = threading.Thread(target=run_driver, daemon=True) thread.start() asyncio.run(app._main()) ended = time.time() for fd in (stdin_r, out_r, out_w) + ((stdin_w,) if ending is not None else ()): os.close(fd) self.assertLess(ended - marks[0], 0.3, "the loop kept sleeping after %r" % (ending,)) def test_wake_is_safe_without_a_loop(self): app = tai.TuiApp() app.wake() app.dirty_paint() def test_chat_has_no_polling_timers_when_idle(self): app = tai.TuiApp() app.rows, app.cols = 30, 100 root, refs = tai.tui_build_chat(app, FakeChatAgent()) app.mount(root) repeating = [handle for handle in app.timers if handle[1] is not None] self.assertEqual(len(repeating), 1, "idle chat timers: %r" % [handle[1] for handle in repeating]) self.assertIsNone(refs.get("spin_timer")) def test_composer_resizes_from_an_event_not_a_timer(self): app = tai.TuiApp() app.rows, app.cols = 30, 100 root, refs = tai.tui_build_chat(app, FakeChatAgent()) app.mount(root) box = refs["box"] app.frame_bytes() height = box.h box.lines = ["a", "b", "c"] self.assertEqual(box.h, 3) box.lines.append("d") self.assertEqual(box.h, 4) box.lines = ["a"] self.assertEqual(box.h, height) class TuiEventTests(unittest.TestCase): def test_bubble_order_and_stop(self): order = [] root = tai.TuiPanel() mid = tai.TuiPanel() leaf = tai.TuiPanel() root.add(mid) mid.add(leaf) root.on_key = lambda event: order.append("root") mid.on_key = lambda event: order.append("mid") leaf.on_key = lambda event: order.append("leaf") leaf.emit(tai.TuiKey(key="x", char="x")) self.assertEqual(order, ["leaf", "mid", "root"]) order.clear() leaf.on_key = lambda event: (order.append("leaf"), event.stop()) leaf.emit(tai.TuiKey(key="x", char="x")) self.assertEqual(order, ["leaf"]) def test_focus_blur_do_not_bubble(self): seen = [] root = tai.TuiPanel() leaf = tai.TuiPanel() root.add(leaf) root.on_focus = lambda event: seen.append("root") leaf.emit(tai.TuiFocus(panel=leaf)) self.assertEqual(seen, []) def test_handler_name(self): self.assertEqual(tai.TuiClick(x=0, y=0, button=0, modifiers=[], pressed=True).handler_name, "on_click") class TuiFocusTests(unittest.TestCase): def make_app(self): app = tai.TuiApp() app.rows = 10 app.cols = 30 return app def test_chain_sorted_by_position(self): app = self.make_app() root = tai.TuiPanel(flow=None) late = tai.TuiButton("late", x=0, y=8, w=10) early = tai.TuiButton("early", x=0, y=1, w=10) root.add(late, early) app.mount(root) self.assertEqual(app.focusables(), [early, late]) def test_tab_wraps_both_directions(self): app = self.make_app() root = tai.TuiPanel(flow=None) first = tai.TuiButton("a", x=0, y=1, w=8) second = tai.TuiButton("b", x=0, y=5, w=8) root.add(first, second) app.mount(root) self.assertIs(app.focused, first) app.dispatch(tai.TuiKey(key="tab", char="\t")) self.assertIs(app.focused, second) app.dispatch(tai.TuiKey(key="tab", char="\t")) self.assertIs(app.focused, first) app.dispatch(tai.TuiKey(key="backtab", char="")) self.assertIs(app.focused, second) def test_tab_skips_disabled_and_hidden(self): app = self.make_app() root = tai.TuiPanel(flow=None) ok = tai.TuiButton("ok", x=0, y=1, w=8) off = tai.TuiButton("off", x=0, y=5, w=8, enabled=False) hid = tai.TuiButton("hid", x=0, y=7, w=8, visible=False) root.add(ok, off, hid) app.mount(root) self.assertEqual(app.focusables(), [ok]) def test_modal_trap_and_outside_click_swallowed(self): app = self.make_app() root = tai.TuiPanel(flow=None) behind = tai.TuiButton("behind", x=0, y=1, w=10) dialog = tai.TuiDialog(TuiFocusTests._ok_button(), x=5, y=2, w=20, h=6) root.add(behind, dialog) app.mount(root) self.assertEqual(app.focusables(), [dialog.children[0]]) app.set_focus(behind) self.assertIsNot(app.focused, behind) pressed = [] behind.on_press = pressed.append app._dispatch_click(tai.TuiClick(x=1, y=1, button=0, modifiers=[], pressed=True)) self.assertEqual(pressed, []) @staticmethod def _ok_button(): return tai.TuiButton("ok", w=10) def test_click_focuses_and_presses(self): app = self.make_app() root = tai.TuiPanel(flow=None) pressed = [] button = tai.TuiButton("go", x=2, y=2, w=12) button.on_press = pressed.append root.add(button) app.mount(root) app._dispatch_click(tai.TuiClick(x=4, y=3, button=0, modifiers=[], pressed=True)) self.assertIs(app.focused, button) self.assertEqual(len(pressed), 1) self.assertIs(pressed[0].panel, button) def test_tab_escapes_terminal(self): app = self.make_app() root = tai.TuiPanel(flow=None) term = tai.TuiTerminal(x=0, y=0, w=20, h=8) other = tai.TuiButton("b", x=0, y=9, w=8, h=1) root.add(term, other) app.mount(root) app.set_focus(term) app.dispatch(tai.TuiKey(key="tab", char="\t")) self.assertIs(app.focused, other) app.dispatch(tai.TuiKey(key="backtab", char="")) self.assertIs(app.focused, term) def test_mount_prefers_non_terminal(self): app = self.make_app() root = tai.TuiPanel(flow=None) term = tai.TuiTerminal(x=0, y=0, w=20, h=8) button = tai.TuiButton("b", x=0, y=9, w=8, h=1) root.add(term, button) app.mount(root) self.assertIs(app.focused, button) def test_modal_close_restores_previous_focus(self): app = self.make_app() root = tai.TuiPanel(flow=None) behind = tai.TuiButton("behind", x=0, y=1, w=10) root.add(behind) app.mount(root) self.assertIs(app.focused, behind) dialog = tai.TuiDialog(TuiFocusTests._ok_button(), x=5, y=2, w=20, h=6) root.add(dialog) self.assertIs(app.focused, dialog.children[0]) app.close_panel(dialog) self.assertIs(app.focused, behind) def test_demo_answer_then_q_quits(self): app = tai.TuiApp() app.rows = 30 app.cols = 100 root, _refs = tai.tui_build_demo(app, live=False) app.mount(root) welcome = root.find("welcome") self.assertIsNotNone(welcome) self.assertIs(app.focused, welcome.picker) app.dispatch(tai.TuiKey(key="enter", char="\r")) self.assertIsNone(root.find("welcome")) actions = root.find("actions") self.assertIsNotNone(actions) self.assertIs(app.focused, actions) app._running = True app.dispatch(tai.TuiKey(key="q", char="q")) self.assertFalse(app._running) class TuiInputTests(unittest.TestCase): def feed(self, *chunks): parser = tai.TuiInput() events = [] for chunk in chunks: events.extend(parser.feed(chunk)) return parser, events def test_sgr_mouse_press_release_wheel(self): _parser, events = self.feed(b"\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;3;3M") self.assertEqual(len(events), 3) press, release, wheel = events self.assertEqual((press.x, press.y, press.button, press.pressed), (9, 4, 0, True)) self.assertFalse(release.pressed) self.assertIsInstance(wheel, tai.TuiScroll) self.assertEqual((wheel.x, wheel.y, wheel.direction), (2, 2, "up")) def test_sgr_modifiers(self): _parser, events = self.feed(b"\x1b[<4;1;1M") self.assertEqual(events[0].modifiers, ["shift"]) self.assertEqual(events[0].button, 0) def test_legacy_mouse(self): _parser, events = self.feed(b"\x1b[M !!") self.assertIsInstance(events[0], tai.TuiClick) self.assertEqual((events[0].x, events[0].y), (0, 0)) _parser, events = self.feed(b"\x1b[M`!!") self.assertIsInstance(events[0], tai.TuiScroll) def test_split_report_buffers(self): parser, events = self.feed(b"\x1b[<0;1") self.assertEqual(events, []) events = parser.feed(b"0;5M") self.assertEqual(len(events), 1) self.assertEqual((events[0].x, events[0].y), (9, 4)) def test_keys(self): _parser, events = self.feed(b"\x1b[A\x1b[Z\x1bOP\r\t\x7fq") self.assertEqual([event.key for event in events], ["up", "backtab", "f1", "enter", "tab", "backspace", "q"]) def test_escape_needs_idle(self): parser, events = self.feed(b"\x1b") self.assertEqual(events, []) self.assertEqual([event.key for event in parser.flush()], ["escape"]) def test_alt_combo(self): _parser, events = self.feed(b"\x1bx") self.assertEqual(events[0].key, "alt+x") def test_ctrl_and_utf8(self): _parser, events = self.feed(b"\x01\xc3\xa9") self.assertEqual(events[0].key, "ctrl+a") self.assertEqual(events[1].key, "é") def test_split_utf8(self): parser, events = self.feed(b"\xc3") self.assertEqual(events, []) events = parser.feed(b"\xa9") self.assertEqual(events[0].key, "é") def test_modified_arrows(self): _parser, events = self.feed(b"\x1b[1;2A") self.assertEqual(events[0].key, "shift+up") def test_bracketed_paste(self): _parser, events = self.feed(b"\x1b[200~hello\nworld\x1b[201~") self.assertEqual(len(events), 1) self.assertIsInstance(events[0], tai.TuiPaste) self.assertEqual(events[0].text, "hello\nworld") def test_split_paste(self): parser, events = self.feed(b"\x1b[200~ab") self.assertEqual(events, []) events = parser.feed(b"cd\x1b[201~") self.assertEqual(len(events), 1) self.assertEqual(events[0].text, "abcd") def test_paste_keeps_escapes_literal(self): _parser, events = self.feed(b"\x1b[200~\x1b[A\tX\x1b[201~") self.assertEqual(len(events), 1) self.assertEqual(events[0].text, "\x1b[A\tX") def test_paste_cap_drops_whole(self): big = b"x" * (tai.TUI_PASTE_CAP + 1) parser, events = self.feed(b"\x1b[200~" + big) self.assertEqual(events, []) self.assertEqual(parser.buf, b"") events = parser.feed(b"\x1b[201~") self.assertEqual(events, []) def test_flush_keeps_partial_paste(self): parser, events = self.feed(b"\x1b[200~ab") self.assertEqual(events, []) self.assertEqual(parser.flush(), []) events = parser.feed(b"c\x1b[201~") self.assertEqual(len(events), 1) self.assertEqual(events[0].text, "abc") def test_crlf_single_enter(self): _parser, events = self.feed(b"a\r\nb") self.assertEqual([event.key for event in events], ["a", "enter", "b"]) def test_lone_cr_waits_for_next_byte(self): parser, events = self.feed(b"\r") self.assertEqual(events, []) self.assertEqual([event.key for event in parser.feed(b"x")], ["enter", "x"]) def test_lone_cr_resolves_on_flush(self): parser, events = self.feed(b"\r") self.assertEqual(events, []) self.assertEqual([event.key for event in parser.flush()], ["enter"]) class TuiKittyTests(unittest.TestCase): def feed(self, *chunks): parser = tai.TuiInput() events = [] for chunk in chunks: events.extend(parser.feed(chunk)) return parser, events def test_shift_press(self): _parser, events = self.feed(b"\x1b[57441;2u") self.assertEqual(len(events), 1) self.assertEqual(events[0].key, "shift") def test_shift_right_press(self): _parser, events = self.feed(b"\x1b[57447;2u") self.assertEqual(events[0].key, "shift") def test_release_ignored(self): _parser, events = self.feed(b"\x1b[57441;1:3u") self.assertEqual(events, []) def test_repeat_modifier_ignored(self): _parser, events = self.feed(b"\x1b[57441;2:2u") self.assertEqual(events, []) def test_text_key(self): _parser, events = self.feed(b"\x1b[97u") self.assertEqual((events[0].key, events[0].char), ("a", "a")) def test_text_repeat_emits(self): _parser, events = self.feed(b"\x1b[97;1:2u") self.assertEqual(events[0].key, "a") def test_shift_letter_uppercases(self): _parser, events = self.feed(b"\x1b[97;2u") self.assertEqual((events[0].key, events[0].char), ("A", "A")) self.assertEqual(getattr(events[0], "modifiers", []), []) def test_ctrl_letter(self): _parser, events = self.feed(b"\x1b[97;5u") self.assertEqual(events[0].key, "ctrl+a") self.assertEqual(events[0].modifiers, ["ctrl"]) def test_functional(self): _parser, events = self.feed(b"\x1b[27u\x1b[13u\x1b[9u\x1b[127u") self.assertEqual([event.key for event in events], ["escape", "enter", "tab", "backspace"]) def test_associated_text_wins(self): _parser, events = self.feed(b"\x1b[49;2;33u") self.assertEqual((events[0].key, events[0].char), ("!", "!")) def test_split_chunk(self): parser, events = self.feed(b"\x1b[57441") self.assertEqual(events, []) events = parser.feed(b";2u") self.assertEqual(len(events), 1) self.assertEqual(events[0].key, "shift") def test_unknown_swallowed(self): _parser, events = self.feed(b"\x1b[59999uq") self.assertEqual([event.key for event in events], ["q"]) def test_stack_forms_ignored(self): _parser, events = self.feed(b"\x1b[>10u\x1b[?u") self.assertEqual(events, []) class TuiVtTests(unittest.TestCase): def test_text_wrap_and_scroll(self): screen = tai.TuiVtScreen(2, 4) screen.feed(b"abcdef\r\ngh") self.assertEqual(screen.text(), "ef\ngh") screen.feed(b"\r\nijk") self.assertEqual(screen.text(), "gh\nijk") def test_cursor_addressing(self): screen = tai.TuiVtScreen(3, 5) screen.feed(b"\x1b[2;3HX") self.assertEqual(screen.text(), "\n X\n") def test_erase_line_and_display(self): screen = tai.TuiVtScreen(2, 5) screen.feed(b"hello\r\nworld\x1b[1K") self.assertEqual(screen.text(), "hello\n") screen.feed(b"\x1b[2J") self.assertEqual(screen.text(), "\n") def test_sgr_colors_stored(self): screen = tai.TuiVtScreen(1, 3) screen.feed(b"\x1b[31mR\x1b[0mN") self.assertEqual(screen.grid[0][0][1], "31") self.assertIsNone(screen.grid[0][1][1]) def test_alt_screen_restores(self): screen = tai.TuiVtScreen(2, 4) screen.feed(b"main") screen.feed(b"\x1b[?1049h") screen.feed(b"\x1b[2Jalt!") self.assertEqual(screen.text(), "alt!\n") screen.feed(b"\x1b[?1049l") self.assertEqual(screen.text(), "main\n") def test_resize_keeps_content(self): screen = tai.TuiVtScreen(2, 4) screen.feed(b"ab\r\ncd") screen.resize(2, 6) self.assertEqual(screen.text(), "ab\ncd") def test_terminal_key_bytes(self): enter = tai.TuiTerminal.key_bytes(tai.TuiKey(key="enter", char="\n")) up = tai.TuiTerminal.key_bytes(tai.TuiKey(key="up", char="")) char = tai.TuiTerminal.key_bytes(tai.TuiKey(key="a", char="a")) ctrl = tai.TuiTerminal.key_bytes(tai.TuiKey(key="ctrl+a", char="\x01")) self.assertEqual((enter, up, char, ctrl), (b"\r", b"\x1b[A", b"a", b"\x01")) class TuiWidgetTests(unittest.TestCase): def make_app(self, rows=12, cols=40): app = tai.TuiApp() app.rows = rows app.cols = cols return app def test_options_move_clamps_and_chooses(self): seen = [] picker = tai.TuiOptions(["a", "b"], on_select=lambda event: seen.append((event.index, event.value))) picker.move(-5) self.assertEqual(picker.selected, 0) picker.move(5) self.assertEqual(picker.selected, 1) picker.choose() self.assertEqual(seen, [(1, "b")]) def test_options_click_selects_row(self): app = self.make_app() root = tai.TuiPanel(flow=None) seen = [] picker = tai.TuiOptions(["a", "b", "c"], x=0, y=0, w=10, h=5, on_select=lambda event: seen.append(event.value)) root.add(picker) app.mount(root) app._dispatch_click(tai.TuiClick(x=1, y=2, button=0, modifiers=[], pressed=True)) self.assertEqual(seen, ["c"]) def test_question_answers_and_closes(self): app = self.make_app() root = tai.TuiPanel(flow=None) seen = [] root.add(tai.TuiText("behind", x=0, y=0, w=40, h=12)) question = tai.TuiQuestion("Pick?", ["x", "y"], on_done=seen.append, w=30) root.add(question) app.mount(root) question.picker.choose(1) self.assertEqual(seen, ["y"]) self.assertIsNone(question.parent) def test_dialog_escape_closes(self): app = self.make_app() root = tai.TuiPanel(flow=None) dialog = tai.TuiDialog(TuiWidgetTests._btn(), w=20, h=6) root.add(dialog) app.mount(root) dialog.emit(tai.TuiKey(key="escape", char="\x1b")) self.assertIsNone(dialog.parent) def test_terminal_paste_queues_bytes(self): term = tai.TuiTerminal() term.on_paste(tai.TuiPaste(text="a\nb")) self.assertEqual(bytes(term._pending), b"a\nb") term.exit_status = 0 term._pending = bytearray() term.on_paste(tai.TuiPaste(text="a\nb")) self.assertEqual(bytes(term._pending), b"") @staticmethod def _btn(): return tai.TuiButton("ok", w=8) def test_log_appends_and_pins(self): log = tai.TuiLog(w=20, h=4) log.arrange(0, 0, 20, 4, (0, 0, 20, 4)) for pos in range(10): log.append("line %d" % pos) self.assertEqual(log.scroll_y, 6) log.scroll_by(0, -4) log.append("line 10") self.assertEqual(log.scroll_y, 2) def test_progress_clamps(self): bar = tai.TuiProgress(9.9) self.assertEqual(bar.value, 1.0) bar.set(-1) self.assertEqual(bar.value, 0.0) def test_animation_interpolates_and_finishes(self): app = self.make_app() panel = tai.TuiPanel(x=0, y=0, w=4, h=1) app.mount(tai.TuiPanel(flow=None)) app.root.add(panel) done = [] panel.animate("x", 10, duration=1.0, easing="linear", on_done=done.append) app.tick_animations(1000.0) self.assertEqual(panel.x, 0) app.tick_animations(1000.5) self.assertEqual(panel.x, 5.0) app.tick_animations(1001.0) self.assertEqual(panel.x, 10.0) self.assertEqual(done, [panel]) self.assertEqual(app.animations, []) def test_timers_fire_and_cancel(self): app = self.make_app() seen = [] once = app.after(9999, lambda: seen.append("once")) once[0] = 0.0 app._fire_timers(100.0) self.assertEqual(seen, ["once"]) seen.clear() app._fire_timers(200.0) self.assertEqual(seen, []) every = app.every(10, lambda: seen.append("tick")) every[0] = 0.0 app._fire_timers(100.0) app._fire_timers(100.0) self.assertEqual(seen, ["tick"]) app.cancel_timer(every) every[0] = 0.0 app._fire_timers(200.0) self.assertEqual(seen, ["tick"]) def test_demo_scene_composes(self): app = tai.TuiApp() app.rows = 30 app.cols = 100 root, refs = tai.tui_build_demo(app, live=False) app.mount(root) refs["shell"].screen.feed(b"$ echo hello\r\nhello\r\n$ ") refs["stream"].screen.feed(b"stream tick 1\r\nstream tick 2\r\n") app.close_panel(root.find("welcome")) text = app.render_text() for needle in ("Actions", "Open question dialog", "stream tick 2", "hello", "Progress", "Events", "overlapping layers", "70%", "demo started"): self.assertIn(needle, text) def test_question_renders_modal_content(self): app = tai.TuiApp() app.rows = 16 app.cols = 50 root = tai.TuiPanel(flow=None) root.add(tai.TuiText("behind", x=0, y=0, w=50, h=16)) root.add(tai.TuiQuestion("Pick a theme:", ["Ocean", "Forest"], on_done=lambda value: None, title="Question", w=30)) app.mount(root) text = app.render_text() for needle in ("Question", "Pick a theme:", "Ocean", "Forest", "Cancel"): self.assertIn(needle, text) class TuiDoubleTapTests(unittest.TestCase): def test_fires_on_second_within_window(self): tap = tai.TuiDoubleTap(timeout=0.4) self.assertFalse(tap.hit("shift", 100.0)) self.assertTrue(tap.hit("shift", 100.2)) def test_no_fire_across_window(self): tap = tai.TuiDoubleTap(timeout=0.4) self.assertFalse(tap.hit("shift", 100.0)) self.assertFalse(tap.hit("shift", 100.5)) self.assertTrue(tap.hit("shift", 100.6)) def test_triple_fires_once(self): tap = tai.TuiDoubleTap(timeout=0.4) self.assertFalse(tap.hit("shift", 100.0)) self.assertTrue(tap.hit("shift", 100.1)) self.assertFalse(tap.hit("shift", 100.2)) def test_other_key_resets(self): tap = tai.TuiDoubleTap(timeout=0.4) self.assertFalse(tap.hit("shift", 100.0)) self.assertFalse(tap.hit("ctrl", 100.1)) self.assertFalse(tap.hit("shift", 100.2)) self.assertTrue(tap.hit("shift", 100.3)) def test_default_timeout(self): self.assertEqual(tai.TuiDoubleTap().timeout, 0.4) class TuiTextBoxTests(unittest.TestCase): def make_box(self, **kwargs): app = tai.TuiApp() app.rows = 10 app.cols = 40 root = tai.TuiPanel(flow=None) kwargs.setdefault("x", 1) kwargs.setdefault("y", 1) kwargs.setdefault("w", 20) kwargs.setdefault("h", 3) box = tai.TuiTextBox(**kwargs) root.add(box) app.mount(root) return app, box def key(self, app, key, char=""): app.dispatch(tai.TuiKey(key=key, char=char)) def test_insert_and_value(self): app, box = self.make_box() self.assertIs(app.focused, box) for char in "abc": self.key(app, char, char) self.assertEqual(box.value, "abc") self.assertEqual((box.cx, box.cy), (3, 0)) def test_enter_splits_line(self): app, box = self.make_box() for char in "ab": self.key(app, char, char) self.key(app, "left", "") self.key(app, "enter", "\n") self.assertEqual(box.value, "a\nb") self.assertEqual((box.cx, box.cy), (0, 1)) def test_backspace_joins_lines(self): app, box = self.make_box() for char in "a": self.key(app, char, char) self.key(app, "enter", "\n") self.key(app, "b", "b") self.key(app, "left", "") self.key(app, "left", "") self.key(app, "backspace", "\x7f") self.assertEqual(box.value, "ab") self.assertEqual((box.cx, box.cy), (1, 0)) def test_delete_joins_lines(self): app, box = self.make_box() for char in "a": self.key(app, char, char) self.key(app, "enter", "\n") self.key(app, "b", "b") self.key(app, "up", "") self.key(app, "delete", "") self.assertEqual(box.value, "ab") self.assertEqual((box.cx, box.cy), (1, 0)) def test_home_end_arrows(self): app, box = self.make_box() for char in "abc": self.key(app, char, char) self.key(app, "home", "") self.assertEqual(box.cx, 0) self.key(app, "end", "") self.assertEqual(box.cx, 3) self.key(app, "left", "") self.assertEqual(box.cx, 2) def test_stop_and_bubble(self): _app, box = self.make_box() event = tai.TuiKey(key="a", char="a") box.emit(event) self.assertTrue(event.stopped) event = tai.TuiKey(key="escape", char="\x1b") box.emit(event) self.assertFalse(event.stopped) def test_click_positions_cursor(self): app, box = self.make_box() for char in "hello": self.key(app, char, char) app.layout() app._dispatch_click(tai.TuiClick(x=4, y=2, button=0, modifiers=[], pressed=True)) self.assertIs(app.focused, box) self.assertEqual((box.cx, box.cy), (2, 0)) def test_space_inserts(self): app, box = self.make_box() self.key(app, "space", " ") self.assertEqual(box.value, " ") def test_paste_multiline(self): app, box = self.make_box() app.dispatch(tai.TuiPaste(text="a\r\nb\rc")) self.assertEqual(box.value, "a\nb\nc") self.assertEqual((box.cx, box.cy), (1, 2)) class TuiAutocompleteTests(unittest.TestCase): def make_box(self, complete=tai.tui_complete_command, **kwargs): app = tai.TuiApp() app.rows = 12 app.cols = 50 root = tai.TuiPanel(flow=None) kwargs.setdefault("x", 1) kwargs.setdefault("y", 1) kwargs.setdefault("w", 30) kwargs.setdefault("h", 3) box = tai.TuiTextBox(complete=complete, **kwargs) root.add(box) app.mount(root) return app, box def key(self, app, key, char=""): app.dispatch(tai.TuiKey(key=key, char=char)) def type(self, app, text): for char in text: self.key(app, "space" if char == " " else char, char) def test_opens_and_filters(self): app, box = self.make_box() self.type(app, "/bo") popup = box.complete_popup self.assertIsNotNone(popup) self.assertEqual([value for _label, value in popup.options], ["bots", "bot"]) self.assertIs(popup.parent, box.root()) self.assertIs(app.focused, box) def test_accept_replaces_word(self): app, box = self.make_box() self.type(app, "/bo") self.key(app, "down", "") self.key(app, "enter", "\n") self.assertEqual(box.value, "/bot ") self.assertEqual(box.cx, 5) self.assertIsNone(box.complete_popup) def test_accept_mid_word_replaces_through_end(self): app, box = self.make_box() self.type(app, "/bot x") for _i in range(5): self.key(app, "left", "") self.key(app, "enter", "\n") self.assertEqual(box.value, "/help x") self.assertEqual(box.cx, 5) self.assertIsNone(box.complete_popup) def test_escape_dismisses_then_bubbles(self): app, box = self.make_box() self.type(app, "/bo") event = tai.TuiKey(key="escape", char="\x1b") box.emit(event) self.assertTrue(event.stopped) self.assertIsNone(box.complete_popup) event = tai.TuiKey(key="escape", char="\x1b") box.emit(event) self.assertFalse(event.stopped) def test_no_match_hides(self): app, box = self.make_box() self.type(app, "/zzz") self.assertIsNone(box.complete_popup) def test_space_hides(self): app, box = self.make_box() self.type(app, "/bot") self.assertIsNotNone(box.complete_popup) self.type(app, " ") self.assertIsNone(box.complete_popup) def test_up_down_navigate(self): app, box = self.make_box() self.type(app, "/bo") popup = box.complete_popup self.assertEqual(popup.selected, 0) self.key(app, "down", "") self.assertEqual(popup.selected, 1) self.key(app, "down", "") self.assertEqual(popup.selected, 1) self.key(app, "up", "") self.assertEqual(popup.selected, 0) self.assertEqual(box.value, "/bo") def test_popup_placed_under_cursor(self): app, box = self.make_box() self.type(app, "/bo") popup = box.complete_popup self.assertEqual((popup.x, popup.y), (4, 3)) def test_popup_flips_above_when_no_room(self): app, box = self.make_box(y=8) self.type(app, "/bo") popup = box.complete_popup self.assertEqual(popup.y, 5) def test_click_accepts(self): app, box = self.make_box() self.type(app, "/bo") app.layout() popup = box.complete_popup app._dispatch_click(tai.TuiClick(x=popup.ax + 1, y=popup.ay + 1, button=0, modifiers=[], pressed=True)) self.assertEqual(box.value, "/bots ") def test_blur_hides(self): app, box = self.make_box() self.type(app, "/bo") self.assertIsNotNone(box.complete_popup) box.emit(tai.TuiBlur(panel=box)) self.assertIsNone(box.complete_popup) def test_unmount_removes_popup(self): app, box = self.make_box() self.type(app, "/bo") popup = box.complete_popup box.parent.remove(box) self.assertIsNone(popup.parent) def test_completer_failure_hides(self): def broken(_prefix): raise ValueError("nope") app, box = self.make_box(complete=broken) self.type(app, "/bo") self.assertIsNone(box.complete_popup) self.assertEqual(box.value, "/bo") def test_tui_complete_command_labels(self): items = tai.tui_complete_command("bo") self.assertEqual([value for _label, value in items], ["bots", "bot"]) self.assertIn("list bots", items[0][0]) plain = [label for label, value in tai.tui_complete_command("exi") if value == "exit"] self.assertEqual(plain, ["/exit"]) def test_mention_provider_accepts(self): app, box = self.make_box(mentions=tai.tui_complete_mention) self.type(app, "@op") popup = box.complete_popup self.assertIsNotNone(popup) self.assertEqual(popup.options[0][1], "opencode") self.key(app, "enter", "\n") self.assertEqual(box.value, "@opencode ") def test_mention_file_accepts(self): with tempfile.TemporaryDirectory() as tmp: with open(os.path.join(tmp, "doc.txt"), "w") as handle: handle.write("x") app, box = self.make_box(mentions=lambda prefix: tai.tui_complete_mention(prefix, directory=tmp)) self.type(app, "@do") popup = box.complete_popup self.assertIsNotNone(popup) self.assertEqual([value for _label, value in popup.options], ["doc.txt"]) self.key(app, "enter", "\n") self.assertEqual(box.value, "@doc.txt ") def test_mention_needs_fn(self): app, box = self.make_box(mentions=None, complete=None) self.type(app, "@op") self.assertIsNone(box.complete_popup) class TuiPromptDialogTests(unittest.TestCase): def make_demo(self): app = tai.TuiApp() app.rows = 30 app.cols = 100 root, refs = tai.tui_build_demo(app, live=False) app.mount(root) app.dispatch(tai.TuiKey(key="enter", char="\r")) app.double_tap.timeout = 3600 return app, root, refs def test_double_shift_opens(self): app, root, _refs = self.make_demo() app.dispatch(tai.TuiKey(key="shift", char="")) self.assertIsNone(root.find("prompt")) app.dispatch(tai.TuiKey(key="shift", char="")) dialog = root.find("prompt") self.assertIsNotNone(dialog) self.assertIs(app.focused, dialog.box) def test_ok_executes(self): app, root, refs = self.make_demo() app.dispatch(tai.TuiKey(key="shift", char="")) app.dispatch(tai.TuiKey(key="shift", char="")) for char in "hi": app.dispatch(tai.TuiKey(key=char, char=char)) app.dispatch(tai.TuiKey(key="tab", char="\t")) app.dispatch(tai.TuiKey(key="enter", char="\r")) self.assertIsNone(root.find("prompt")) self.assertEqual(bytes(refs["shell"]._pending), b"hi\n") def test_cancel_closes_without_executing(self): app, root, refs = self.make_demo() app.dispatch(tai.TuiKey(key="shift", char="")) app.dispatch(tai.TuiKey(key="shift", char="")) app.dispatch(tai.TuiKey(key="x", char="x")) app.dispatch(tai.TuiKey(key="escape", char="\x1b")) self.assertIsNone(root.find("prompt")) self.assertEqual(bytes(refs["shell"]._pending), b"") def test_double_tab_still_cycles(self): app, root, _refs = self.make_demo() actions = root.find("actions") self.assertIs(app.focused, actions) app.dispatch(tai.TuiKey(key="tab", char="\t")) app.dispatch(tai.TuiKey(key="tab", char="\t")) self.assertIs(app.focused, actions) self.assertIsNone(root.find("prompt")) def test_complete_passthrough(self): dialog = tai.TuiPromptDialog(complete=tai.tui_complete_command) self.assertIs(dialog.box.complete_fn, tai.tui_complete_command) def test_demo_prompt_has_command_completion(self): app, root, _refs = self.make_demo() app.dispatch(tai.TuiKey(key="shift", char="")) app.dispatch(tai.TuiKey(key="shift", char="")) dialog = root.find("prompt") self.assertIs(dialog.box.complete_fn, tai.tui_complete_command) class TuiCommandTests(unittest.TestCase): def test_demo_in_commands_and_help(self): self.assertIn("demo", tai.COMMANDS) self.assertIn("/demo", tai.HELP_TEXT) def test_parser_demo_flags(self): parser = tai.build_parser() args = parser.parse_args(["--demo"]) self.assertTrue(args.demo) self.assertFalse(args.demo_shot) args = parser.parse_args(["--demo-shot"]) self.assertTrue(args.demo_shot) class TuiThemeTests(unittest.TestCase): def tearDown(self): tai.tui_set_theme("dark") def test_builtin_registry_complete(self): self.assertEqual(set(tai.TUI_THEMES), {"dark", "light", "nord", "gruvbox", "ansi"}) for name, theme in tai.TUI_THEMES.items(): for role in tai.TUI_THEME_ROLES: self.assertTrue(theme.color(role), "%s lacks %s" % (name, role)) self.assertFalse(tai.TUI_THEMES["light"].dark) self.assertTrue(tai.TUI_THEMES["dark"].dark) def test_role_resolves_at_draw(self): tai.tui_set_theme("dark") self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;139;92;246m") self.assertEqual(tai.TuiStyle(bg="role:bg").sgr(), "\x1b[0;48;2;10;14;26m") tai.tui_set_theme("light") self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;109;74;255m") def test_unknown_role_is_empty(self): self.assertEqual(tai.TuiStyle(fg="role:nope").sgr(), "\x1b[0m") def test_raw_colors_pass_through(self): self.assertEqual(tai.TuiStyle(fg="#ff0000").sgr(), "\x1b[0;38;2;255;0;0m") self.assertEqual(tai.TuiStyle(fg="cyan").sgr(), "\x1b[0;36m") def test_register_validates_roles(self): with self.assertRaises(ValueError): tai.tui_register_theme(tai.TuiTheme("broken", True, {"bg": "black"})) roles = {role: "white" for role in tai.TUI_THEME_ROLES} tai.tui_register_theme(tai.TuiTheme("custom-1", True, roles)) try: tai.tui_set_theme("custom-1") self.assertEqual(tai.TuiStyle(fg="role:fg").sgr(), "\x1b[0;37m") finally: del tai.TUI_THEMES["custom-1"] def test_resolve_precedence(self): self.assertEqual(tai.tui_resolve_theme_name("nord"), "nord") old = os.environ.get("TAI_THEME") os.environ["TAI_THEME"] = "gruvbox" try: self.assertEqual(tai.tui_resolve_theme_name(None), "gruvbox") self.assertEqual(tai.tui_resolve_theme_name("light"), "light") finally: if old is None: os.environ.pop("TAI_THEME", None) else: os.environ["TAI_THEME"] = old self.assertEqual(tai.tui_resolve_theme_name(None), "dark") with self.assertRaises(ValueError) as raised: tai.tui_resolve_theme_name("nope") self.assertIn("dark", str(raised.exception)) def test_set_theme_redraws(self): app = tai.TuiApp() app._need_paint = False app.set_theme("nord") self.assertEqual(tai.tui_theme().name, "nord") self.assertTrue(app._need_paint) with self.assertRaises(ValueError): app.set_theme("nope") def test_set_theme_repaints_every_cell_on_the_terminal(self): """Regression: after a theme switch only cells whose text changed got the new colors, leaving old-theme speckles on spaces and unchanged glyphs.""" def screen(theme, switch_to=None): tai.tui_set_theme(theme) app = tai.TuiApp() app.rows, app.cols = 10, 40 app.mount(tai.TuiText("hello world and more words", style=tai.TuiStyle(fg="role:fg", bg="role:bubble_in"))) vt = tai.TuiVtScreen(10, 40) vt.feed(app.frame_bytes().encode("utf-8")) if switch_to: app.set_theme(switch_to) vt.feed(app.frame_bytes().encode("utf-8")) return vt.grid self.assertEqual(screen("dark", "gruvbox"), screen("gruvbox")) def test_cycle_order(self): self.assertEqual(tai.tui_cycle_theme_name("dark"), "light") self.assertEqual(tai.tui_cycle_theme_name("ansi"), "dark") self.assertEqual(tai.tui_cycle_theme_name("nope"), "dark") class FakeChatChat: provider_pin = None model_pin = None class FakeChatStore: def list_bots(self, profile): return [{"name": "main", "nicknames": []}, {"name": "coder", "nicknames": []}] def load_session(self, profile, bot): return [] class FakeChatAgent: profile = "t" bot = "main" def __init__(self): self.chat = FakeChatChat() self.store = FakeChatStore() self.messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there", "tool_calls": []}] self.turns = [] def switch_bot(self, name, silent=False): self.bot = name return "switched to bot '%s'" % name def run_turn(self, text, capture=False): self.turns.append(text) return "echo: %s" % text def checkpoint(self): pass class TuiChatTests(unittest.TestCase): def tearDown(self): tai.tui_set_theme("dark") def make_chat(self, cols=100, rows=24): app = tai.TuiApp() app.rows = rows app.cols = cols agent = FakeChatAgent() root, refs = tai.tui_build_chat(app, agent, spawn=self.spawn) app.mount(root) refs["replay"]() return app, agent, root, refs @staticmethod def spawn(base, name): child = FakeChatAgent() child.bot = name child.messages = [{"role": "system", "content": "sys"}] return child def key(self, app, key, char=""): app.dispatch(tai.TuiKey(key=key, char=char)) def type(self, app, text): for char in text: self.key(app, char, char) def test_prefer_puts_devplace_molodetz_first(self): chat = tai.ChatClient.__new__(tai.ChatClient) chat.provider_pin = chat.model_pin = None chat.prefer = (tai.DEVPLACE_LABEL, "molodetz") chat.config = type("C", (), {"devplace_key": "k", "devplace_base": "https://x/v1"})() other = {"provider": "openrouter", "model": "a", "chat_url": "u", "models_url": "m", "key": "", "profile": ""} out = chat._apply_pins([other]) self.assertEqual((out[0]["provider"], out[0]["model"]), (tai.DEVPLACE_LABEL, "molodetz")) self.assertIs(out[1], other) def test_sidebar_width_clamps(self): self.assertEqual(tai.tui_chat_sidebar_w(100), 30) self.assertEqual(tai.tui_chat_sidebar_w(200), 36) self.assertEqual(tai.tui_chat_sidebar_w(60), 24) self.assertEqual(tai.tui_chat_sidebar_w(80), 24) def test_layout_and_render(self): app, _agent, _root, refs = self.make_chat() self.assertEqual(refs["sidebar_wrap"].w, 30) self.assertEqual(refs["bots"], ["main", "coder"]) self.assertIn("t/main", refs["status"].text) text = app.render_text() self.assertIn("Bots", text) self.assertIn("coder", text) self.assertIn("hello", text) self.assertIn("Write a message...", text) self.assertIn("main", text) self.assertIn("✦ tai", text) self.assertIn("online", text) def test_long_transcript_shows_tail_not_blank(self): app, _agent, _root, refs = self.make_chat(rows=24) refs["box"].focus() for n in range(60): refs["show"]("bot", "line number %d" % n, None) transcript = refs["transcript"] self.assertGreater(len(transcript.lines), transcript._content_h()) text = app.render_text() self.assertIn("line number 59", text) self.assertNotIn("line number 0 ", text) def test_many_messages_stay_pinned_and_scroll(self): app, _agent, _root, refs = self.make_chat(rows=24) refs["box"].focus() transcript = refs["transcript"] for n in range(tai.TUI_CHAT_LINE_CAP): self.type(app, "m%d" % n) self.key(app, "enter") refs["run_pending"]() self.assertLessEqual(len(transcript.lines), tai.TUI_CHAT_LINE_CAP + 10) self.assertIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) transcript.scroll_by(0, -10) self.assertNotIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) transcript.scroll_by(0, 10) self.assertIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) def test_narrow_hides_and_restores_sidebar(self): app, _agent, root, refs = self.make_chat(cols=100) self.assertTrue(refs["sidebar_wrap"].visible) app.cols = 70 app.dispatch(tai.TuiResize(panel=root, rows=24, cols=70)) self.assertFalse(refs["sidebar_wrap"].visible) app.cols = 100 app.dispatch(tai.TuiResize(panel=root, rows=24, cols=100)) self.assertTrue(refs["sidebar_wrap"].visible) def test_offload_keeps_loop_ticking(self): import asyncio app = tai.TuiApp() done = [] async def go(): app._loop = asyncio.get_running_loop() started = time.time() app.offload(lambda: (time.sleep(0.3), "x")[1], lambda result, error: done.append((result, error))) ticks = 0 while not done: ticks += 1 await asyncio.sleep(0.01) app._loop = None return ticks, time.time() - started ticks, elapsed = asyncio.run(go()) self.assertEqual(done, [("x", None)]) self.assertGreater(ticks, 10) self.assertGreaterEqual(elapsed, 0.3) def test_offload_reports_errors_and_runs_inline_headless(self): app = tai.TuiApp() got = [] app.offload(lambda: 1 / 0, lambda result, error: got.append((result, type(error).__name__))) self.assertEqual(got, [(None, "ZeroDivisionError")]) def test_busy_turn_blocks_second_send_and_bot_switch(self): app, agent, _root, refs = self.make_chat() refs["do_send"]("one") self.assertEqual(refs["head_status"].text, "thinking...") refs["do_send"]("two") self.assertEqual(agent.turns, []) refs["run_pending"]() self.assertEqual(agent.turns, ["one"]) self.assertFalse(refs["busy"]) def test_status_follows_real_phases(self): app, agent, _root, refs = self.make_chat() self.assertEqual(refs["head_status"].text, "online") refs["do_send"]("go") agent.on_phase = None phases = [] original = agent.run_turn def turn(text, capture=False): agent.on_phase("tool", "shell") refs["paint_status"]() phases.append(refs["head_status"].text) agent.on_phase("typing") refs["paint_status"]() phases.append(refs["head_status"].text) return original(text, capture) agent.run_turn = turn refs["run_pending"]() self.assertEqual(phases, ["running shell...", "typing..."]) self.assertEqual(refs["head_status"].text, "online") def test_typing_is_shown_only_while_reply_reveals(self): import asyncio app, agent, _root, refs = self.make_chat() long_reply = "word " * 80 agent.run_turn = lambda text, capture=False: long_reply seen = [] async def go(): app._loop = asyncio.get_running_loop() refs["do_send"]("hello") seen.append(refs["head_status"].text) refs["run_pending"]() for _ in range(200): app._fire_timers(time.time() + 1) await asyncio.sleep(0.01) seen.append(refs["head_status"].text) if not refs["busy"]: break app._loop = None asyncio.run(go()) self.assertEqual(seen[0], "thinking...") self.assertIn("typing...", seen) self.assertEqual(seen[-1], "online") self.assertLess(seen.index("typing..."), len(seen) - 1) self.assertTrue(all(item in ("thinking...", "typing...", "online") for item in seen)) shown = " ".join(line for line, _spans in refs["transcript"].lines) self.assertIn("word word word", shown) self.assertEqual(refs["blocks"][-1][2], long_reply) def test_status_shimmer_changes_each_frame_but_text_is_stable(self): app, _agent, _root, refs = self.make_chat() refs["do_send"]("go") refs["paint_status"]() first = [(span.text, span.style.key()) for span in refs["head_status"].rows[0]] refs["spin"] += 2 refs["paint_status"]() second = [(span.text, span.style.key()) for span in refs["head_status"].rows[0]] self.assertNotEqual(first, second) self.assertEqual("".join(x for x, _k in first), "".join(x for x, _k in second)) def test_frame_stays_inside_budget_on_large_screen(self): app = tai.TuiApp() app.rows, app.cols = 50, 200 agent = FakeChatAgent() for n in range(40): agent.messages += [{"role": "user", "content": "q %d" % n}, {"role": "assistant", "content": "answer " + "word " * 60}] root, refs = tai.tui_build_chat(app, agent) app.mount(root) refs["replay"]() app.frame_bytes() started = time.perf_counter() for _ in range(20): app.dirty_paint() app.frame_bytes() per_frame = (time.perf_counter() - started) / 20 self.assertLess(per_frame, 1.0 / tai.TUI_FPS, "frame %.1fms over the %.1fms budget" % (per_frame * 1000, 1000.0 / tai.TUI_FPS)) def run_prompt(self, drive, kind="approve", text="ls -la", options=("Allow once", "Always allow (approvals off)", "Deny")): """Drive prompter() from a worker thread against a live asyncio loop; drive(app, refs) answers the dialog. Returns the prompter result.""" import asyncio app, _agent, _root, refs = self.make_chat() result = [] async def go(): app._loop = asyncio.get_running_loop() app.offload(lambda: refs["prompter"](kind, text, list(options)), lambda value, error: result.append((value, error))) for _ in range(300): await asyncio.sleep(0.01) if app.top_modal() is not None: break app.layout() self.assertIsNotNone(app.top_modal(), "dialog never opened") self.shown = app.render_text() drive(app, refs) for _ in range(300): await asyncio.sleep(0.01) if result: break app._loop = None asyncio.run(go()) self.assertEqual(len(result), 1, "prompter never returned") self.assertIsNone(result[0][1]) return result[0][0] def test_approval_dialog_allows_once(self): value = self.run_prompt(lambda app, refs: self.key(app, "enter")) self.assertEqual(value, "Allow once") self.assertIn("Approval needed", self.shown) self.assertIn("ls -la", self.shown) def test_approval_dialog_deny_and_escape(self): def deny(app, refs): self.key(app, "down") self.key(app, "down") self.key(app, "enter") self.assertEqual(self.run_prompt(deny), "Deny") self.assertIsNone(self.run_prompt(lambda app, refs: self.key(app, "escape"))) def test_ask_dialog_free_text_and_typed_answer(self): def type_it(app, refs): for char in "blue": self.key(app, char, char) app.top_modal()._run() self.assertEqual(self.run_prompt(type_it, "ask", "Favorite color?", ()), "blue") def pick_other(app, refs): self.key(app, "down") self.key(app, "down") self.key(app, "enter") app.layout() for char in "mauve": self.key(app, char, char) app.top_modal()._run() self.assertEqual(self.run_prompt(pick_other, "ask", "Pick?", ("red", "green")), "mauve") def test_live_block_renders_as_message_with_fixed_window(self): app, _agent, _root, refs = self.make_chat() lines = ["line %d" % n for n in range(30)] refs["blocks"].append(["live", "make build", lines, ""]) refs["replay"]() app.layout() shown = app.render_text() self.assertIn("make build", shown) self.assertIn("line 29", shown) self.assertNotIn("line 21\n", shown) rows = tai.tui_chat_live_rows("make build", lines, "exit 0", 40, tai.TuiStyle()) self.assertEqual(len(rows), 1 + tai.TUI_CHAT_LIVE_ROWS + 1) def test_live_stream_from_turn_thread_lands_in_transcript(self): import asyncio app, _agent, _root, refs = self.make_chat() session = refs.session done = [] def work(): stream = refs["live_stream"]("echo hi", session) for n in range(5): stream.feed("out %d\n" % n) stream.close("exit 0 · 0.1s") return True async def go(): app._loop = asyncio.get_running_loop() app.offload(work, lambda value, error: done.append((value, error))) for _ in range(300): await asyncio.sleep(0.01) app._fire_timers(time.time()) if done: await asyncio.sleep(0.2) app._fire_timers(time.time()) break app.layout() self.shown = app.render_text() app._loop = None asyncio.run(go()) self.assertEqual(done, [(True, None)]) self.assertIn("echo hi", self.shown) self.assertIn("out 4", self.shown) self.assertIn("exit 0", self.shown) def test_live_stream_feeds_block_and_caps(self): block = ["live", "ls", [], ""] pushed = [] stream = tai.TuiLiveStream(block, lambda: pushed.append(1)) for n in range(tai.TUI_CHAT_LIVE_KEEP + 50): stream.feed("\033[31mrow %d\033[0m\n" % n) stream.close("exit 0") self.assertEqual(len(block[2]), tai.TUI_CHAT_LIVE_KEEP) self.assertEqual(block[2][-1], "row %d" % (tai.TUI_CHAT_LIVE_KEEP + 49)) self.assertEqual(block[3], "exit 0") def test_prompter_without_loop_denies(self): app, _agent, _root, refs = self.make_chat() self.assertIsNone(refs["prompter"]("approve", "rm x", ["Allow once", "Deny"])) def test_agent_hooks_use_prompter_for_approval_and_questions(self): import types calls = [] stub = types.SimpleNamespace(yolo=False, auto=False, persist=True, user_stepped_in=False, config=types.SimpleNamespace(auto_approve=False)) stub.prompter = lambda kind, text, options: calls.append(kind) or "Allow once" self.assertTrue(tai.Agent.ask_approval(stub, "ls")) stub.prompter = lambda kind, text, options: "Deny" self.assertFalse(tai.Agent.ask_approval(stub, "ls")) stub.prompter = lambda kind, text, options: "Always allow (approvals off)" self.assertTrue(tai.Agent.ask_approval(stub, "ls")) self.assertTrue(stub.yolo) self.assertTrue(stub.config.auto_approve) stub.yolo = False stub.prompter = lambda kind, text, options: None self.assertFalse(tai.Agent.ask_approval(stub, "ls")) def test_paint_and_winsize_survive_redirected_stdout(self): """A turn thread swaps sys.stdout for a StringIO; the loop must keep painting to the real terminal fd and keep reading its size.""" app, _agent, _root, _refs = self.make_chat() read_fd, write_fd = os.pipe() app._stdout_fd = write_fd app._running = True app.dirty_paint() old = sys.stdout sys.stdout = io.StringIO() try: self.assertTrue(app._paint_frame()) app._winsize() finally: sys.stdout = old os.close(write_fd) painted = os.read(read_fd, 1 << 20) os.close(read_fd) self.assertTrue(app._running) self.assertIn(b"\x1b[", painted) def test_history_class_readline_semantics(self): hist = tai.TuiHistory() self.assertIsNone(hist.older("draft")) for item in ("one", "two", "two", "three", " ", ""): hist.add(item) self.assertEqual(hist.entries, ["one", "two", "three"]) self.assertEqual(hist.older("half typed"), "three") self.assertEqual(hist.older("ignored"), "two") self.assertEqual(hist.older("ignored"), "one") self.assertIsNone(hist.older("ignored")) self.assertEqual(hist.newer(), "two") self.assertEqual(hist.newer(), "three") self.assertEqual(hist.newer(), "half typed") self.assertIsNone(hist.newer()) def vault(self, passphrase="hist-test-1", profile="t"): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) old = os.environ.get("TAI_HOME") os.environ["TAI_HOME"] = tmp.name self.addCleanup(lambda: os.environ.pop("TAI_HOME", None) if old is None else os.environ.update(TAI_HOME=old)) class FakeArgs: yes = True FakeArgs.profile = profile config = tai.Config(FakeArgs()) store = tai.Store(config, tai.Seal(config.home, passphrase)) self.addCleanup(store.close) return store, config def test_vault_history_roundtrip_dedup_and_cap(self): store, _config = self.vault() self.assertTrue(store.history_add("t", "main", "one")) self.assertFalse(store.history_add("t", "main", "one")) self.assertFalse(store.history_add("t", "main", " ")) store.history_add("t", "coder", "multi\nline") self.assertEqual(store.history_load("t"), ["one", "multi\nline"]) for n in range(tai.INPUT_HISTORY_CAP + 25): store.history_add("t", "main", "m%d" % n) loaded = store.history_load("t") self.assertEqual(len(loaded), tai.INPUT_HISTORY_CAP) self.assertEqual(loaded[-1], "m%d" % (tai.INPUT_HISTORY_CAP + 24)) count = store.db.execute("SELECT COUNT(*) FROM input_history").fetchone()[0] self.assertEqual(count, tai.INPUT_HISTORY_CAP) def test_vault_history_is_sealed_on_disk_and_scoped_per_profile(self): store, _config = self.vault() store.history_add("t", "main", "my secret plan") store.history_add("other", "main", "elsewhere") raw = store.db.execute("SELECT text FROM input_history WHERE profile = 't'").fetchone()[0] self.assertTrue(raw.startswith("tai1$")) self.assertNotIn("secret", raw) self.assertEqual(store.history_load("t"), ["my secret plan"]) self.assertEqual(store.history_load("other"), ["elsewhere"]) bots = [row[0] for row in store.db.execute("SELECT bot FROM input_history ORDER BY id")] self.assertEqual(bots, ["main", "main"]) def test_vault_history_unsealed_store_and_sealed_rows_refuse(self): store, config = self.vault(passphrase="") store.history_add("t", "main", "plain") self.assertEqual(store.history_load("t"), ["plain"]) store.db.execute("UPDATE input_history SET text = 'tai1$abc'") store.db.commit() with self.assertRaises(tai.SealError): tai.Store(config, tai.Seal(config.home, "")) def test_vault_history_imports_old_readline_file_once(self): store, config = self.vault() with open(config.history_path, "w", encoding="utf-8") as handle: handle.write("old one\n\nold two\n") self.assertEqual(store.history_load("t"), ["old one", "old two"]) with open(config.history_path, "a", encoding="utf-8") as handle: handle.write("added later\n") self.assertEqual(store.history_load("t"), ["old one", "old two"]) def test_tui_history_uses_vault_backend_and_survives_restart(self): store, _config = self.vault() agent = type("A", (), {})() agent.store, agent.profile, agent.bot = store, "t", "main" hist = tai.TuiHistory(tai.VaultHistory.for_agent(agent)) hist.add("persisted") hist.add("persisted") again = tai.TuiHistory(tai.VaultHistory.for_agent(agent)) self.assertEqual(again.entries, ["persisted"]) self.assertIsNone(tai.VaultHistory.for_agent(FakeChatAgent())) def test_tui_history_counts_backend_failures_and_keeps_memory(self): class Broken: def load(self): raise RuntimeError("down") def append(self, text): raise RuntimeError("down") hist = tai.TuiHistory(Broken()) hist.add("x") self.assertEqual(hist.entries, ["x"]) self.assertEqual(hist.write_errors, 1) small = tai.TuiHistory(cap=3) for n in range(10): small.add("m%d" % n) self.assertEqual(small.entries, ["m7", "m8", "m9"]) def test_up_down_recall_history_in_composer(self): app, _agent, _root, refs = self.make_chat() box = refs["box"] box.focus() for text in ("first msg", "second msg"): self.type(app, text) self.key(app, "enter") refs["run_pending"]() self.type(app, "unfinished") self.key(app, "up") self.assertEqual(box.value, "second msg") self.key(app, "up") self.assertEqual(box.value, "first msg") self.key(app, "up") self.assertEqual(box.value, "first msg") self.key(app, "down") self.assertEqual(box.value, "second msg") self.key(app, "down") self.assertEqual(box.value, "unfinished") self.key(app, "down") self.assertEqual(box.value, "unfinished") def test_up_moves_cursor_inside_multiline_before_recalling(self): app, _agent, _root, refs = self.make_chat() box = refs["box"] box.focus() self.type(app, "old") self.key(app, "enter") refs["run_pending"]() box.insert("top\nbottom") self.assertEqual(box.cy, 1) self.key(app, "up") self.assertEqual((box.cy, box.value), (0, "top\nbottom")) self.key(app, "up") self.assertEqual(box.value, "old") def test_completion_popup_keeps_arrow_priority_over_history(self): app, _agent, _root, refs = self.make_chat() box = refs["box"] box.focus() self.type(app, "earlier") self.key(app, "enter") refs["run_pending"]() self.type(app, "/") self.assertIsNotNone(box.complete_popup) self.key(app, "up") self.assertEqual(box.value, "/") def test_slash_commands_enter_history_too(self): app, _agent, _root, refs = self.make_chat() box = refs["box"] box.focus() self.type(app, "/help") if box.complete_popup is not None: self.key(app, "enter") self.key(app, "enter") self.assertEqual(box.history.entries[-1].strip(), "/help") def test_other_bot_stays_usable_while_one_is_busy(self): """A long task in one bot must not block talking to another.""" import asyncio import threading app, agent, _root, refs = self.make_chat() gate = threading.Event() agent.run_turn = lambda text, capture=False: (gate.wait(5), "slow answer to " + text)[1] refs["box"].focus() seen = {} async def tick(seconds): end = time.time() + seconds while time.time() < end: app._fire_timers(time.time() + 1) await asyncio.sleep(0.01) async def go(): app._loop = asyncio.get_running_loop() refs["do_send"]("long ssh task") await tick(0.15) self.assertTrue(refs.session.busy) main = refs.session refs["select_bot"]("coder") self.assertEqual(refs.session.name, "coder") self.assertFalse(refs.session.busy) self.assertEqual(refs["head_status"].text, "online") coder_agent = refs.session.agent self.assertIsNot(coder_agent, agent) refs["do_send"]("hi coder") for _ in range(100): await tick(0.02) if coder_agent.turns: break seen["coder_turns"] = list(coder_agent.turns) for _ in range(200): await tick(0.02) if not refs.session.busy: break seen["coder_done"] = not refs.session.busy seen["main_still_busy"] = main.busy seen["sidebar_busy"] = bool(refs["sidebar"].row_meta["main"]["busy"]) gate.set() for _ in range(300): await tick(0.02) if not main.busy: break seen["main_done"] = not main.busy seen["main_unread"] = main.unread seen["main_text"] = [block[2] for block in main.blocks if block[0] == "msg" and block[1] == "in"] app._loop = None asyncio.run(go()) self.assertEqual(seen["coder_turns"], ["hi coder"]) self.assertTrue(seen["coder_done"]) self.assertTrue(seen["main_still_busy"]) self.assertTrue(seen["sidebar_busy"]) self.assertTrue(seen["main_done"]) self.assertEqual(seen["main_unread"], 1) self.assertEqual(seen["main_text"][-1], "slow answer to long ssh task") refs["select_bot"]("main") self.assertEqual(refs.session.unread, 0) self.assertIn("slow answer to long ssh task", " ".join(line for line, _spans in refs["transcript"].lines)) def test_background_prompt_waits_until_you_open_that_bot(self): import asyncio app, _agent, _root, refs = self.make_chat() result = [] seen = {} async def go(): app._loop = asyncio.get_running_loop() refs["select_bot"]("coder") coder = refs.session refs["select_bot"]("main") app.offload(lambda: refs["prompter"]("approve", "ls", ["Allow once", "Deny"], coder), lambda value, error: result.append(value)) await asyncio.sleep(0.2) seen["modal_while_away"] = app.top_modal() is not None seen["badge"] = refs["sidebar"].row_meta["coder"].get("busy") refs["do_send"]("main is still usable") seen["main_usable"] = refs.session.pending == "main is still usable" refs["select_bot"]("coder") for _ in range(300): await asyncio.sleep(0.01) if app.top_modal() is not None: break app.layout() self.shown = app.render_text() self.key(app, "enter") for _ in range(300): await asyncio.sleep(0.01) if result: break app._loop = None asyncio.run(go()) self.assertFalse(seen["modal_while_away"]) self.assertEqual(seen["badge"], "?") self.assertTrue(seen["main_usable"]) self.assertIn("Approval needed - coder", self.shown) self.assertEqual(result, ["Allow once"]) def test_stdio_guard_is_counted_across_threads(self): guard = tai.TuiStdioGuard() real = (sys.stdout, sys.stdin) with guard: inner = sys.stdout self.assertIsNot(inner, real[0]) with guard: self.assertIs(sys.stdout, inner) self.assertIs(sys.stdout, inner) self.assertEqual((sys.stdout, sys.stdin), real) def test_bot_command_switches_session_not_the_shared_agent(self): app, agent, _root, refs = self.make_chat() original = agent.bot agent.handle = None with mock.patch.object(tai, "handle_command", side_effect=lambda a, text: (setattr(a, "bot", "coder"), True)[1]): refs["do_send"]("/bot coder") self.assertEqual(agent.bot, original) self.assertEqual(refs.session.name, "coder") def test_hidden_sidebar_frees_its_columns(self): app, _agent, _root, refs = self.make_chat(cols=100) right = refs["transcript"].parent self.assertEqual(right.ax, 30) refs["box"].focus() self.key(app, "f2") app.layout() self.assertEqual(right.ax, 0) self.assertEqual(right.aw, 100) def test_narrow_start_hides_sidebar_and_compacts_send(self): app, _agent, _root, refs = self.make_chat(cols=44) self.assertFalse(refs["sidebar_wrap"].visible) self.assertEqual(refs["send_btn"].w, 5) def test_f2_manual_toggle_overrides_auto(self): app, _agent, root, refs = self.make_chat(cols=100) refs["box"].focus() self.key(app, "f2") self.assertFalse(refs["sidebar_wrap"].visible) app.dispatch(tai.TuiResize(panel=root, rows=24, cols=100)) self.assertFalse(refs["sidebar_wrap"].visible) self.key(app, "f2") self.assertTrue(refs["sidebar_wrap"].visible) def test_f4_cycles_theme(self): app, _agent, _root, refs = self.make_chat() refs["box"].focus() self.key(app, "f4") self.assertEqual(tai.tui_theme().name, "light") self.assertIn("theme: light", refs["status"].text) def test_enter_sends_and_turn_runs(self): app, agent, _root, refs = self.make_chat() refs["box"].focus() self.type(app, "hi bot") self.key(app, "enter") self.assertEqual(refs["pending"], "hi bot") self.assertEqual(refs["box"].value, "") self.assertIn("thinking", refs["status"].text) refs["run_pending"]() self.assertEqual(agent.turns, ["hi bot"]) shown = [line for line, _spans in refs["transcript"].lines] self.assertTrue(any("hi bot" in line for line in shown)) self.assertTrue(any("echo: hi bot" in line for line in shown)) self.assertNotIn("thinking", refs["status"].text) def test_turn_stdout_is_buried(self): _app, agent, _root, refs = self.make_chat() def noisy(text, capture=False): print("CARD-JUNK-TO-STDOUT") agent.turns.append(text) return "done" agent.run_turn = noisy refs["pending"] = "go" buf = io.StringIO() with contextlib.redirect_stdout(buf): refs["run_pending"]() self.assertEqual(buf.getvalue(), "") shown = [line for line, _spans in refs["transcript"].lines] self.assertTrue(any("done" in line for line in shown)) def test_send_ignores_empty_and_busy(self): _app, _agent, _root, refs = self.make_chat() refs["do_send"](" ") self.assertIsNone(refs["pending"]) refs["pending"] = "one" refs["do_send"]("two") self.assertEqual(refs["pending"], "one") def test_newline_keys_insert_in_send_mode(self): app, _agent, _root, refs = self.make_chat() refs["box"].focus() self.key(app, "alt+enter", "\n") self.key(app, "shift+enter", "") self.assertEqual(len(refs["box"].lines), 3) self.assertIsNone(refs["pending"]) def test_completion_enter_accepts_instead_of_send(self): app, _agent, _root, refs = self.make_chat() refs["box"].focus() self.type(app, "/he") self.assertIsNotNone(refs["box"].complete_popup) self.key(app, "enter") self.assertIsNone(refs["pending"]) self.assertTrue(refs["box"].value.startswith("/help")) def test_max_chars_truncates(self): _app, _agent, _root, refs = self.make_chat() refs["box"].insert("x" * 2005) self.assertEqual(len(refs["box"].value), 2000) def test_slash_help_and_quit(self): app, agent, _root, refs = self.make_chat() refs["do_send"]("/help") shown = "\n".join(line for line, _style in refs["transcript"].lines) self.assertIn("this text", shown) self.assertEqual(agent.turns, []) refs["do_send"]("/quit") self.assertFalse(app._running) def test_bot_switch_keeps_drafts(self): app, agent, _root, refs = self.make_chat() refs["box"].focus() self.type(app, "draft-1") refs["sidebar"].choose(1) self.assertEqual(refs.session.name, "coder") self.assertEqual(refs.session.agent.bot, "coder") self.assertEqual(agent.bot, "main") self.assertEqual(refs["box"].value, "") self.assertEqual(refs["head_name"].text, "coder") refs["sidebar"].choose(0) self.assertEqual(refs.session.name, "main") self.assertIs(refs.session.agent, agent) self.assertEqual(refs["box"].value, "draft-1") def test_strip_ansi(self): self.assertEqual(tai.tui_strip_ansi("\x1b[1;32mok\x1b[0m"), "ok") self.assertEqual(tai.tui_strip_ansi("plain"), "plain") def bubble_styles(self): plain = tai.TuiStyle() return {"base": plain, "bubble": plain, "meta": plain, "tick": plain} def row_text(self, row): return "".join(span.text for span in row) def test_bubble_rows_align(self): rows = tai.tui_chat_bubble_rows("hi", 60, "out", self.bubble_styles(), "12:00", tai.TUI_CHAT_TICK_READ, True) texts = [self.row_text(row) for row in rows] self.assertTrue(all(len(text) == 60 for text in texts)) self.assertEqual(len(texts), 3) self.assertTrue(texts[0].startswith(" ")) self.assertIn("hi", texts[1]) self.assertTrue(texts[2].rstrip().endswith("12:00 " + tai.TUI_CHAT_TICK_READ)) rows = tai.tui_chat_bubble_rows("hi", 60, "in", self.bubble_styles(), sender="main") texts = [self.row_text(row) for row in rows] self.assertTrue(texts[0].startswith(" main")) self.assertTrue(texts[1].startswith(" hi")) self.assertTrue(texts[1].endswith(" ")) def test_bubble_rows_cap_width(self): rows = tai.tui_chat_bubble_rows("x" * 100, 60, "in", self.bubble_styles()) self.assertTrue(len(rows) > 3) body = [self.row_text(row) for row in rows][1:-1] self.assertEqual(len(body[0]), 60) self.assertEqual(body[0].strip(), "x" * 38) self.assertEqual("".join(line.strip() for line in body), "x" * 100) def test_bubble_rows_meta_row_keeps_body(self): rows = tai.tui_chat_bubble_rows("first line here with padding words second line tail end", 60, "out", self.bubble_styles(), "12:00", "x") texts = [self.row_text(row) for row in rows] self.assertTrue(any("first line here" in line for line in texts)) self.assertTrue(any("tail end" in line for line in texts)) self.assertIn("12:00", texts[-1]) def test_bubble_rows_narrow_degrade(self): rows = tai.tui_chat_bubble_rows("hi there", 20, "out", self.bubble_styles(), "12:00", "x") text = self.row_text(rows[0]) self.assertTrue(text.startswith("hi there")) self.assertIn("12:00", self.row_text(rows[-1])) def test_markdown_blocks(self): blocks = tai.tui_md_blocks("# Title\n\nplain **bold** and `code`\n- one\n2. two\n> quote\n```py\nx = 1\n```\n---") kinds = [kind for kind, _segs, _lead in blocks] self.assertEqual(kinds, ["h", "blank", "p", "li", "li", "quote", "code", "rule"]) self.assertEqual(blocks[2][1], [("plain ", ""), ("bold", "b"), (" and ", ""), ("code", "c")]) self.assertEqual(blocks[3][2], "•") self.assertEqual(blocks[4][2], "2.") def test_wrap_segs_chops_long_words_and_keeps_tags(self): lines = tai.tui_wrap_segs([("aaaa bbbb ", ""), ("cccccccccc", "b")], 6) self.assertEqual(["".join(text for text, _tag in line) for line in lines], ["aaaa", "bbbb", "cccccc", "cccc"]) self.assertEqual(lines[2][0][1], "b") def test_markdown_markers_not_shown_in_bubble(self): rows = tai.tui_chat_bubble_rows("use **bold** and `code`", 60, "in", self.bubble_styles()) text = " ".join(self.row_text(row) for row in rows) self.assertIn("use bold and code", text) self.assertNotIn("**", text) self.assertNotIn("`", text) def test_tool_chip_summarizes_json_and_truncates(self): row = tai.tui_chat_tool_row("fork", '{"task": "do it", "n": 2}', 60, tai.TuiStyle()) text = self.row_text(row) self.assertEqual(len(text), 60) self.assertIn("fork", text) self.assertIn("task=do it", text) self.assertIn("n=2", text) short = self.row_text(tai.tui_chat_tool_row("fork", '{"task": "' + "z" * 200 + '"}', 40, tai.TuiStyle())) self.assertEqual(len(short), 40) self.assertIn("…", short) def test_composer_and_sidebar_fit_screen(self): app, _agent, _root, refs = self.make_chat(rows=24) lines = app.render_text().split("\n") self.assertIn("╰", lines[-2]) self.assertIn("Send", lines[-3]) self.assertIn("F2", lines[-1]) def test_pill_row_centered(self): spans = tai.tui_chat_pill_row("Today", 40, tai.TuiStyle(), tai.TuiStyle()) text = "".join(span.text for span in spans) self.assertEqual(len(text), 40) self.assertEqual(text.strip(), "Today") self.assertTrue(text.startswith(" ")) def test_ticks_settle_on_reply(self): app, agent, _root, refs = self.make_chat() refs["do_send"]("hi bot") shown = " ".join(line for line, _spans in refs["transcript"].lines) self.assertIn(tai.TUI_CHAT_TICK_SEND, shown) self.assertNotIn(tai.TUI_CHAT_TICK_READ, shown) self.assertEqual(refs["head_status"].text, "thinking...") refs["run_pending"]() shown = " ".join(line for line, _spans in refs["transcript"].lines) self.assertIn(tai.TUI_CHAT_TICK_READ, shown) self.assertNotIn(tai.TUI_CHAT_TICK_SEND, shown) self.assertEqual(refs["head_status"].text, "online") def test_date_pill_on_empty_history(self): app = tai.TuiApp() app.rows = 24 app.cols = 100 agent = FakeChatAgent() agent.messages = [{"role": "system", "content": "sys"}] _root, refs = tai.tui_build_chat(app, agent) app.mount(_root) refs["replay"]() shown = " ".join(line for line, _spans in refs["transcript"].lines) import datetime as datetime_mod now = datetime_mod.datetime.now() self.assertIn("%s %d" % (now.strftime("%B"), now.day), shown) def test_chat_list_two_line_rows(self): app, _agent, _root, refs = self.make_chat() sidebar = refs["sidebar"] rows = sidebar.content_rows() texts = ["".join(span.text for span in row) for row in rows] self.assertIn("main", texts[0]) self.assertIn("hi there", texts[1]) self.assertIn("coder", texts[2]) refs["box"].focus() self.type(app, "draft-1") sidebar.choose(1) rows = sidebar.content_rows() texts = ["".join(span.text for span in row) for row in rows] self.assertIn("Draft:", texts[1]) def test_chat_list_click_maps_pairs(self): _app, agent, _root, refs = self.make_chat() sidebar = refs["sidebar"] event = tai.TuiClick(panel=sidebar, x=sidebar.ax + 2, y=sidebar.ay + 2, pressed=True) sidebar.on_click(event) self.assertEqual(refs.session.name, "coder") event = tai.TuiClick(panel=sidebar, x=sidebar.ax + 2, y=sidebar.ay + 0, pressed=True) sidebar.on_click(event) self.assertEqual(refs.session.name, "main") def test_send_button_press_sends(self): _app, _agent, root, refs = self.make_chat() refs["box"].focus() refs["box"].insert("via button") root.find("send").on_press(None) self.assertEqual(refs["pending"], "via button") def test_telegram_roles_registered(self): self.assertEqual(len(tai.TUI_THEME_ROLES), 27) for role in ("bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", "service_bg", "service_fg", "unread", "unread_muted"): self.assertIn(role, tai.TUI_THEME_ROLES) tai.tui_set_theme("dark") self.assertEqual(tai.TuiStyle(bg="role:bubble_out").sgr(), "\x1b[0;48;2;67;56;202m") def test_parser_flags(self): args = tai.build_parser().parse_args(["--tui", "--theme", "nord"]) self.assertTrue(args.tui) self.assertEqual(args.theme, "nord") class EmbeddedStringsTests(unittest.TestCase): def test_box_scripts_compile(self): for name in ("BOX_STT", "BOX_TTS"): compile(getattr(tai, name), "<%s>" % name, "exec") def test_containerfile_shape(self): self.assertTrue(tai.BOX_CONTAINERFILE.startswith("FROM ")) self.assertIn("\nRUN ", tai.BOX_CONTAINERFILE) self.assertIn("\nCOPY ", tai.BOX_CONTAINERFILE) def test_systemd_units_shape(self): for name in ("SCHEDULER_UNIT", "TELEGRAM_UNIT"): value = getattr(tai, name) self.assertTrue(value.startswith("[Unit]")) self.assertIn("\n[Service]\n", value) class NoOpencodeFlagTests(unittest.TestCase): def test_flag_disables_opencode(self): args = tai.build_parser().parse_args(["--no-opencode", "hi"]) self.assertTrue(args.no_opencode) self.assertFalse(tai.Config(args).opencode_enabled) def test_default_keeps_opencode(self): args = tai.build_parser().parse_args(["hi"]) with mock.patch.dict(os.environ, {"TAI_OPENCODE": ""}): self.assertTrue(tai.Config(args).opencode_enabled) class UpdateModelsTests(unittest.TestCase): def test_replace_list_prunes_only_that_provider(self): with tempfile.TemporaryDirectory() as home: health = tai.ModelHealth(home) health.store_list("opencode:free", ["old-free", "kept-free"]) health.record("opencode", "old-free", False) health.record("opencode", "kept-free", True) health.record("openrouter", "old-free", True) added, removed = health.replace_list("opencode:free", "opencode", ["kept-free", "new-free"]) self.assertEqual(added, ["new-free"]) self.assertEqual(removed, ["old-free"]) self.assertEqual(health.entry("opencode", "old-free"), {}) self.assertTrue(health.entry("opencode", "kept-free")) self.assertTrue(health.entry("openrouter", "old-free")) self.assertEqual(tai.ModelHealth(home).cached_list("opencode:free"), ["kept-free", "new-free"]) def test_empty_discovery_keeps_old_list(self): with tempfile.TemporaryDirectory() as home: health = tai.ModelHealth(home) health.store_list("opencode:free", ["a"]) self.assertEqual(health.replace_list("opencode:free", "opencode", []), ([], [])) self.assertEqual(health.cached_list("opencode:free"), ["a"]) def test_flag_parses(self): self.assertTrue(tai.build_parser().parse_args(["--update-models"]).update_models) class MplexKeyTests(unittest.TestCase): def test_canonical_names(self): K = tai.MplexKeys self.assertEqual(K.canonical("C-b"), "C-b") self.assertEqual(K.canonical("^A"), "C-a") self.assertEqual(K.canonical("M-C-b"), "C-M-b") self.assertEqual(K.canonical("PageUp"), "PPage") self.assertEqual(K.canonical("Delete"), "DC") self.assertEqual(K.canonical("S-a"), "A") self.assertEqual(K.canonical("C-m"), "Enter") self.assertEqual(K.canonical("C-i"), "Tab") self.assertEqual(K.canonical(" "), "Space") self.assertEqual(K.canonical("S-Up"), "S-Up") with self.assertRaises(tai.MplexError): K.canonical("Nonsense") with self.assertRaises(tai.MplexError): K.canonical("") def test_split_decodes_terminal_bytes(self): tokens, rest = tai.MplexKeys.split(b"\x02c\x1b[A\x1bx\x1b[1;5A\x1b[5~\x1bOP\x1b[1;3D\x1b[Z\x00\x7f \r\n") self.assertEqual(rest, b"") self.assertEqual([name for name, _raw in tokens], ["C-b", "c", "Up", "M-x", "C-Up", "PPage", "F1", "M-Left", "BTab", "C-Space", "BSpace", "Space", "Enter", "C-j"]) def test_lone_escape_waits_for_final(self): self.assertEqual(tai.MplexKeys.split(b"\x1b", False), ([], b"\x1b")) self.assertEqual(tai.MplexKeys.split(b"\x1b", True), ([("Escape", b"\x1b")], b"")) tokens, rest = tai.MplexKeys.split(b"\x1b[", True) self.assertEqual([name for name, _raw in tokens], ["Escape", "["]) def test_partial_utf8_waits(self): tokens, rest = tai.MplexKeys.split(b"\xc3", False) self.assertEqual((tokens, rest), ([], b"\xc3")) tokens, rest = tai.MplexKeys.split(b"\xc3\xa9", False) self.assertEqual(tokens, [("é", b"\xc3\xa9")]) def test_to_bytes_round_trips_names(self): K = tai.MplexKeys for name in ("C-b", "M-x", "C-Up", "S-Up", "M-Left", "F5", "PPage", "Enter", "Space", "BSpace", "Tab", "C-Space", "C-M-b", "F1"): tokens, _rest = K.split(K.to_bytes(name), True) self.assertEqual([item[0] for item in tokens], [name], name) def test_application_cursor_form(self): self.assertEqual(tai.MplexKeys.app_cursor_form(b"\x1b[A"), b"\x1bOA") self.assertEqual(tai.MplexKeys.app_cursor_form(b"\x1b[1;5A"), b"\x1b[1;5A") self.assertEqual(tai.MplexKeys.to_bytes("Up", app_cursor=True), b"\x1bOA") class MplexCommandParserTests(unittest.TestCase): def test_quotes_blocks_and_separators(self): parsed = tai.MplexCommandParser.parse('command-prompt -I "#S" { rename-session "%%" } ; new-window -n \'a b\'') self.assertEqual(parsed, [["command-prompt", "-I", "#S", 'rename-session "%%"'], ["new-window", "-n", "a b"]]) def test_escaped_semicolon_stays_a_word(self): self.assertEqual(tai.MplexCommandParser.parse(r"bind x kill-pane \; kill-window"), [["bind", "x", "kill-pane", ";", "kill-window"]]) def test_newlines_separate_commands(self): self.assertEqual(tai.MplexCommandParser.parse("a\nb c"), [["a"], ["b", "c"]]) def test_unterminated_input_is_an_error(self): for bad in ('x "abc', "x 'abc", "x { abc"): with self.assertRaises(tai.MplexError): tai.MplexCommandParser.parse(bad) def test_quote_round_trip(self): for word in ("plain", "with space", 'say "hi"', "a;b", "", "$HOME"): self.assertEqual(tai.MplexCommandParser.parse("x " + tai.MplexCommandParser.quote(word))[0][1:], [word]) def test_args_parse(self): args = tai.MplexArgs.parse("t", "bdt:l:", ["-bd", "-t", "foo", "-l5", "rest", "-x"], 0, None) self.assertTrue(args.has("b") and args.has("d")) self.assertEqual(args.get("t"), "foo") self.assertEqual(args.int("l"), 5) self.assertEqual(args.positional, ["rest", "-x"]) with self.assertRaises(tai.MplexError): tai.MplexArgs.parse("t", "b", ["-z"], 0, None) with self.assertRaises(tai.MplexError): tai.MplexArgs.parse("t", "t:", ["-t"], 0, None) with self.assertRaises(tai.MplexError): tai.MplexArgs.parse("t", "", ["a", "b"], 0, 1) class MplexDefaultsTests(unittest.TestCase): def test_every_default_binding_parses_and_resolves(self): unsupported = set() for table, key, command, repeat, note in tai.MPLEX_DEFAULT_KEYS: tai.MplexKeys.canonical(key) for words in tai.MplexCommandParser.parse(command): try: tai.MplexCommands.resolve(words[0]) except tai.MplexError as exc: self.assertIn("not supported", str(exc), command) unsupported.add(words[0]) self.assertEqual(unsupported, {"display-menu", "customize-mode"}) def test_tmux_default_prefix_table(self): config = tai.MplexConfig() expect = {"c": "new-window", "d": "detach-client", "n": "next-window", "p": "previous-window", "[": "copy-mode", "]": "paste-buffer -p", "z": "resize-pane -Z", "%": "split-window -h", "\"": "split-window", "x": 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', "C-b": "send-prefix", "Space": "next-layout", "M-5": "select-layout tiled", "C-z": "suspend-client", "t": "clock-mode"} for key, command in expect.items(): self.assertEqual(config.lookup("prefix", key).command, command, key) self.assertTrue(config.lookup("prefix", "Up").repeat) self.assertTrue(config.lookup("prefix", "C-Up").repeat) self.assertFalse(config.lookup("prefix", "c").repeat) self.assertEqual(config.options.get("prefix"), "C-b") def test_copy_mode_tables_present(self): config = tai.MplexConfig() self.assertEqual(config.lookup("copy-mode", "C-w").command, "send-keys -X copy-pipe-and-cancel") self.assertEqual(config.lookup("copy-mode-vi", "Space").command, "send-keys -X begin-selection") self.assertEqual(config.lookup("copy-mode-vi", "j").command, "send-keys -X cursor-down") self.assertGreaterEqual(len(config.tables["copy-mode"]), 60) self.assertGreaterEqual(len(config.tables["copy-mode-vi"]), 75) def test_every_copy_mode_command_exists(self): config = tai.MplexConfig() for table in ("copy-mode", "copy-mode-vi"): for key, binding in config.tables[table].items(): for words in tai.MplexCommandParser.parse(binding.command): if words[:2] == ["send-keys", "-X"]: self.assertTrue(hasattr(tai.MplexCopyMode, "c_" + words[2].replace("-", "_")), words) class MplexConfigTests(unittest.TestCase): def load(self, text): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "mplex.toml") with open(path, "w", encoding="utf-8") as handle: handle.write(text) return tai.MplexConfig.load(path) def test_missing_file_is_defaults(self): config = tai.MplexConfig.load("/nonexistent/mplex.toml") self.assertEqual(config.errors, []) self.assertEqual(config.options.get("prefix"), "C-b") def test_options_and_rebinding(self): config = self.load('[options]\nprefix = "C-a"\nbase-index = 1\nstatus = false\nmode-keys = "vi"\n' '[keys.prefix]\n"|" = "split-window -h"\nc = false\nr = { command = "source-file x", repeat = true, note = "reload" }\n' '[keys.root]\n"M-Left" = "previous-window"\n') self.assertEqual(config.errors, []) self.assertEqual(config.options.get("prefix"), "C-a") self.assertEqual(config.options.get("base-index"), 1) self.assertFalse(config.options.get("status")) self.assertEqual(config.options.get("mode-keys"), "vi") self.assertEqual(config.lookup("prefix", "|").command, "split-window -h") self.assertIsNone(config.lookup("prefix", "c")) self.assertTrue(config.lookup("prefix", "r").repeat) self.assertEqual(config.lookup("prefix", "r").note, "reload") self.assertEqual(config.lookup("root", "M-Left").command, "previous-window") def test_clear_table(self): config = self.load('[keys]\nclear = ["prefix"]\n[keys.prefix]\nc = "new-window"\n') self.assertEqual(list(config.tables["prefix"]), ["c"]) def test_bad_entries_are_skipped_loudly(self): config = self.load('[options]\nbogus = 1\nbase-index = "x"\nescape-time = 99999999\n' '[keys.prefix]\nx = "no-such-command"\nNonsenseKey = "new-window"\ny = 5\nz = { command = "new-window", wat = 1 }\n' '[nothing]\na = 1\n') self.assertEqual(config.skipped, 8) joined = "\n".join(config.errors) for needle in ("options.bogus", "options.base-index", "options.escape-time", "keys.prefix.x", "keys.prefix.NonsenseKey", "keys.prefix.y", "keys.prefix.z", "[nothing]"): self.assertIn(needle, joined) self.assertEqual(config.lookup("prefix", "c").command, "new-window") def test_invalid_toml_keeps_defaults(self): config = self.load("this is [ not toml") self.assertEqual(config.skipped, 1) self.assertIn("not valid TOML", config.errors[0]) self.assertEqual(config.lookup("prefix", "c").command, "new-window") def test_error_list_is_capped(self): text = "[options]\n" + "".join("bad%d = 1\n" % n for n in range(200)) config = self.load(text) self.assertEqual(len(config.errors), tai.MplexConfig.ERRORS_MAX) self.assertEqual(config.skipped, 200) def test_options_validation(self): options = tai.MplexOptions() self.assertEqual(options.set("status-position", "top"), "top") with self.assertRaises(tai.MplexError): options.set("status-position", "middle") with self.assertRaises(tai.MplexError): options.set("history-limit", tai.MPLEX_HISTORY_CAP + 1) with self.assertRaises(tai.MplexError): options.set("mouse", "on") self.assertEqual(options.set("prefix", "C-a"), "C-a") self.assertEqual(options.set("prefix2", "None"), "None") child = tai.MplexOptions(options) self.assertEqual(child.get("status-position"), "top") child.set("status-position", "bottom") self.assertEqual(options.get("status-position"), "top") child.unset("status-position") self.assertEqual(child.get("status-position"), "top") child.toggle("status") self.assertFalse(child.get("status")) class MplexFormatTests(unittest.TestCase): def expand(self, text, **values): return tai.MplexFormat.expand(text, lambda name: values.get(name)) def test_variables_and_shorthands(self): self.assertEqual(self.expand("[#S] #I:#W#F", session_name="main", window_index="2", window_name="vim", window_flags="*"), "[main] 2:vim*") self.assertEqual(self.expand("##x #{session_name}", session_name="s"), "#x s") def test_conditionals(self): self.assertEqual(self.expand("#{?window_flags,#{window_flags},-}", window_flags="*"), "*") self.assertEqual(self.expand("#{?window_flags,#{window_flags},-}", window_flags=""), "-") self.assertEqual(self.expand("#{?a,1,#{?b,2,3}}", a="0", b="1"), "2") self.assertEqual(self.expand("#{?#{==:#{x},ok},yes,no}", x="ok"), "yes") self.assertEqual(self.expand("#{?x,a#,b,c}", x="1"), "a,b") def test_modifiers(self): self.assertEqual(self.expand("#{=5:title}", title="abcdefghij"), "abcde") self.assertEqual(self.expand("#{=-3:title}", title="abcdefghij"), "hij") self.assertEqual(self.expand("#{=/4/...:title}", title="abcdefghij"), "abcd...") self.assertEqual(self.expand("#{n:title}", title="abc"), "3") self.assertEqual(self.expand("#{b:path}", path="/a/b/c.txt"), "c.txt") self.assertEqual(self.expand("#{E:fmt}", fmt="#{name}", name="x"), "x") self.assertEqual(self.expand("#{m:w*,#{n}}", n="word"), "1") self.assertEqual(self.expand("#{s/a/b/:v}", v="banana"), "bbnbnb") self.assertEqual(self.expand("#{e|+:1,2}"), "3") self.assertEqual(self.expand("#{&&:1,0}"), "0") self.assertEqual(self.expand("#{!:0}"), "1") def test_styles_pass_through_and_strip(self): text = self.expand("#[fg=red]x#[default]") self.assertEqual(text, "#[fg=red]x#[default]") self.assertEqual(tai.MplexFormat.strip_styles(text), "x") def test_unknown_and_deep_recursion_are_empty(self): self.assertEqual(self.expand("a#{nope}b"), "ab") self.assertEqual(self.expand("#{E:r}", r="#{E:r}"), "") def test_time_expansion(self): self.assertEqual(tai.MplexFormat.with_time("%Y", 0), time.strftime("%Y", time.localtime(0))) self.assertEqual(tai.MplexFormat.with_time("no percent"), "no percent") def test_status_cells_apply_style_markers(self): base = (None, "42", 0) cells = tai.MplexStatusText.cells("a#[fg=red,bold]b#[default]c", base) self.assertEqual(cells[0], ("a", None, "42", 0)) self.assertEqual(cells[1], ("b", "31", "42", 1)) self.assertEqual(cells[2], ("c", None, "42", 0)) class MplexStyleTests(unittest.TestCase): def test_parse(self): self.assertEqual(tai.MplexStyle.parse("bg=green,fg=black"), ("30", "42", 0)) self.assertEqual(tai.MplexStyle.parse("fg=colour123,bold"), ("38;5;123", None, 1)) self.assertEqual(tai.MplexStyle.parse("fg=#ff0010"), ("38;2;255;0;16", None, 0)) self.assertEqual(tai.MplexStyle.parse("fg=brightred"), ("91", None, 0)) self.assertEqual(tai.MplexStyle.parse("bold,nobold"), (None, None, 0)) self.assertEqual(tai.MplexStyle.parse("noattr,bg=yellow,fg=black"), ("30", "43", 0)) with self.assertRaises(tai.MplexError): tai.MplexStyle.parse("fg=nocolour") class MplexLayoutTests(unittest.TestCase): def build(self, count, width=80, height=24): panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(count)] root = tai.MplexLayoutNode("pane", pane=panes[0], w=width, h=height) leaf = root return panes, root, leaf def test_split_matches_tmux_sizes(self): panes, root, leaf = self.build(2) root, fresh = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, fresh.w), (40, 39)) self.assertEqual((fresh.x, fresh.y, fresh.h), (41, 0, 24)) root, low = tai.MplexLayout.split(root, fresh, "tb", False, tai.MplexPane(3, 1, 1, 10, tempfile.gettempdir())) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((fresh.h, low.h, low.y), (12, 11, 13)) def test_split_before_and_size(self): panes, root, leaf = self.build(2) root, fresh = tai.MplexLayout.split(root, leaf, "tb", True, panes[1], size=5) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((fresh.y, fresh.h, leaf.y, leaf.h), (0, 5, 6, 18)) def test_split_without_room(self): panes, root, leaf = self.build(2, width=2, height=24) with self.assertRaises(tai.MplexError): tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) def test_remove_gives_space_back_and_collapses(self): panes, root, leaf = self.build(3) root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) tai.MplexLayout.assign(root, 0, 0, 80, 24) root, third = tai.MplexLayout.split(root, second, "tb", False, panes[2]) tai.MplexLayout.assign(root, 0, 0, 80, 24) root = tai.MplexLayout.remove(root, third) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual(second.h, 24) root = tai.MplexLayout.remove(root, second) self.assertIs(root, leaf) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, leaf.h), (80, 24)) self.assertIsNone(tai.MplexLayout.remove(root, leaf)) def test_resize_moves_the_border(self): panes, root, leaf = self.build(2) root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) tai.MplexLayout.assign(root, 0, 0, 80, 24) tai.MplexLayout.resize_by(leaf, "lr", 5, True) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, second.w), (45, 34)) tai.MplexLayout.resize_by(second, "lr", -3, False) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, second.w), (42, 37)) tai.MplexLayout.resize_by(leaf, "lr", 1000, True) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual(second.w, 1) with self.assertRaises(tai.MplexError): tai.MplexLayout.resize_by(leaf, "tb", 1, True) def test_presets_match_tmux_3_6(self): """Expected sizes were produced by the installed tmux 3.6 for an 80x24 window (new-session -d -x 80 -y 24).""" panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(5)] for name, count, expect in ( ("even-horizontal", 3, [(26, 24)] * 3), ("even-horizontal", 4, [(20, 24), (19, 24), (19, 24), (19, 24)]), ("even-vertical", 2, [(80, 12), (80, 11)]), ("even-vertical", 3, [(80, 8), (80, 7), (80, 7)]), ("main-vertical", 2, [(78, 24), (1, 24)]), ("main-vertical", 3, [(78, 24), (1, 12), (1, 11)]), ("main-horizontal", 3, [(80, 22), (40, 1), (39, 1)]), ("tiled", 2, [(80, 11), (80, 12)]), ("tiled", 3, [(39, 11), (40, 11), (80, 12)]), ("tiled", 4, [(39, 11), (40, 11), (39, 12), (40, 12)]), ): root = tai.MplexLayout.preset(name, panes[:count], 80, 24, 80, 24) tai.MplexLayout.assign(root, 0, 0, 80, 24) sizes = [(leaf.w, leaf.h) for leaf in root.leaves()] self.assertEqual(sizes, expect, "%s %d" % (name, count)) def test_mirrored_presets_keep_pane_order_and_flip_positions(self): panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(3)] root = tai.MplexLayout.preset("main-vertical-mirrored", panes, 80, 24, 80, 24) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual([leaf.pane.id for leaf in root.leaves()], [0, 1, 2]) self.assertEqual([(leaf.x, leaf.y, leaf.w, leaf.h) for leaf in root.leaves()], [(2, 0, 78, 24), (0, 0, 1, 12), (0, 13, 1, 11)]) root = tai.MplexLayout.preset("main-horizontal-mirrored", panes, 80, 24, 80, 24) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual([(leaf.x, leaf.y, leaf.w, leaf.h) for leaf in root.leaves()], [(0, 2, 80, 22), (0, 0, 40, 1), (41, 0, 39, 1)]) def test_split_before_gives_the_new_pane_the_larger_half(self): panes, root, leaf = self.build(2, height=24) root, fresh = tai.MplexLayout.split(root, leaf, "lr", True, panes[1]) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((fresh.w, leaf.w, fresh.x, leaf.x), (40, 39, 0, 41)) def test_resize_to_moves_the_border_the_pane_has(self): panes, root, leaf = self.build(2) root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) tai.MplexLayout.assign(root, 0, 0, 80, 24) tai.MplexLayout.resize_to(second, "lr", 20) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, second.w), (59, 20)) tai.MplexLayout.resize_to(leaf, "lr", 30) tai.MplexLayout.assign(root, 0, 0, 80, 24) self.assertEqual((leaf.w, second.w), (30, 49)) def test_neighbour_by_direction_wraps(self): panes, root, leaf = self.build(3) root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) root, third = tai.MplexLayout.split(root, second, "tb", False, panes[2]) tai.MplexLayout.assign(root, 0, 0, 80, 24) for node in root.leaves(): node.pane.place(node.x, node.y, node.w, node.h) near = lambda current, direction: tai.MplexLayout.neighbour(root_panes, current, direction, 80, 24) root_panes = [node.pane for node in root.leaves()] self.assertIs(near(panes[0], "R"), panes[1]) self.assertIs(near(panes[1], "D"), panes[2]) self.assertIs(near(panes[2], "U"), panes[1]) self.assertIs(near(panes[2], "L"), panes[0]) self.assertIs(near(panes[0], "L"), panes[1] if panes[1].last_active >= panes[2].last_active else panes[2]) self.assertIsNone(tai.MplexLayout.neighbour([panes[0]], panes[0], "U", 80, 24)) class MplexCanvasTests(unittest.TestCase): def test_encode_round_trips_through_a_terminal(self): canvas = tai.MplexCanvas(5, 20) canvas.put(2, 1, "hello", ("31", "42", 1)) canvas.put(0, 4, "bottom") canvas.cursor = (3, 1) data, snapshot = canvas.encode(None) screen = tai.TuiVtScreen(5, 20) screen.feed(data) lines = screen.text().split("\n") self.assertEqual(lines[1], " hello") self.assertEqual(lines[4], "bottom") self.assertEqual(screen.grid[1][2], ("h", "31", "42", 1)) self.assertEqual((screen.cx, screen.cy), (3, 1)) def test_second_frame_repaints_only_changed_rows(self): canvas = tai.MplexCanvas(4, 10) canvas.put(0, 0, "aaaa") canvas.put(0, 3, "dddd") data, snapshot = canvas.encode(None) canvas.put(0, 3, "DDDD") data2, _snapshot2 = canvas.encode(snapshot) self.assertIn(b"\x1b[4;1H", data2) self.assertNotIn(b"\x1b[1;1H", data2) self.assertNotIn(b"\x1b[2J", data2) self.assertIn(b"\x1b[2J", data) def test_border_glyphs_follow_connectivity(self): glyph = tai.MplexCanvas.border_char self.assertEqual(glyph(True, True, False, False, "single"), "│") self.assertEqual(glyph(False, False, True, True, "single"), "─") self.assertEqual(glyph(True, True, False, True, "single"), "├") self.assertEqual(glyph(True, True, True, True, "single"), "┼") self.assertEqual(glyph(False, True, True, True, "double"), "╦") self.assertEqual(glyph(True, False, True, True, "single"), "┴") def test_blit_clips_and_fill(self): canvas = tai.MplexCanvas(3, 6) canvas.blit(4, 1, [[("x", None, None, 0)] * 5], 5) self.assertEqual(canvas.cells[1][4][0], "x") self.assertEqual(canvas.cells[1][5][0], "x") canvas.fill(0, 0, 3, 1, (None, "41", 0), "#") self.assertEqual(canvas.cells[0][2], ("#", None, "41", 0)) class MplexVtExtensionTests(unittest.TestCase): def test_scrollback_collects_scrolled_rows(self): screen = tai.TuiVtScreen(3, 10) screen.history = tai.collections.deque(maxlen=4) screen.feed(b"1\r\n2\r\n3\r\n4\r\n5\r\n") self.assertEqual(["".join(c[0] if c else " " for c in row).strip() for row in screen.history], ["1", "2", "3"]) self.assertEqual(screen.history_pushed, 3) screen.feed(b"6\r\n7\r\n8\r\n") self.assertEqual(len(screen.history), 4) self.assertEqual(screen.history_pushed, 6) def test_scroll_region_and_alt_screen_do_not_feed_history(self): screen = tai.TuiVtScreen(5, 10) screen.history = tai.collections.deque(maxlen=50) screen.feed(b"\x1b[2;4r\x1b[4;1H\n\n\n") self.assertEqual(len(screen.history), 0) screen.feed(b"\x1b[r\x1b[?1049h\x1b[5;1H\n\n\n") self.assertEqual(len(screen.history), 0) def test_device_replies_and_modes(self): screen = tai.TuiVtScreen(5, 10) screen.feed(b"\x1b[3;4H\x1b[6n\x1b[c\x1b[5n") self.assertEqual(screen.take_replies(), b"\x1b[3;4R\x1b[?1;2c\x1b[0n") self.assertEqual(screen.take_replies(), b"") screen.feed(b"\x1b[?1h\x1b[?2004h") self.assertTrue(screen.app_cursor and screen.bracketed_paste) screen.feed(b"\x1b[?1l\x1b[?2004l") self.assertFalse(screen.app_cursor or screen.bracketed_paste) def test_title_graphics_repeat_and_extra_attributes(self): screen = tai.TuiVtScreen(3, 10) screen.feed(b"\x1b]0;my title\x07\x1b(0lqk\x1b(Bx\x1b[3;5;8mA\x1b[0mB\x1b[3b") self.assertEqual(screen.title, "my title") self.assertEqual("".join(cell[0] for cell in screen.grid[0][:4]), "┌─┐x") self.assertEqual(screen.grid[0][4][3], tai.TuiVtScreen.ATTR_ITALIC | tai.TuiVtScreen.ATTR_BLINK | tai.TuiVtScreen.ATTR_HIDDEN) self.assertEqual(screen.grid[0][5][3], 0) self.assertEqual("".join(cell[0] for cell in screen.grid[0][5:9]), "BBBB") class MplexStubServer: """Just enough server for pane modes: options, bindings, a copy sink.""" def __init__(self): self.config = tai.MplexConfig() self.options = tai.MplexOptions() self.copied = [] self.messages = [] self.dirty = 0 def message(self, client, text, milliseconds=None): self.messages.append(text) def pane_options(self, pane): return self.options def mark_dirty(self, window=None): self.dirty += 1 def store_copy(self, text, command="", append=False): self.copied.append((text, command, append)) def run_binding(self, client, binding, pane=None): words = tai.MplexCommandParser.parse(binding.command)[0] pane.top_mode().command(words[2], words[3:], client) # Expected copy mode results produced by the installed tmux 3.6 (an attached client, a 30x6 window, mode-keys # emacs or vi): (mode-keys, "cmd;cmd*N;...", repeat count of the last command, "cx cy scroll_position") and # (mode-keys, commands, text that ended up in the paste buffer). "name*N" repeats a command N times. MPLEX_TMUX_CASES = ( ('emacs', 'cursor-up', 1, '25 4 0'), ('emacs', 'cursor-down', 1, '0 5 0'), ('emacs', 'cursor-left', 1, '25 4 0'), ('emacs', 'cursor-right', 1, '0 5 0'), ('emacs', 'start-of-line', 1, '0 5 0'), ('emacs', 'end-of-line', 1, '0 5 0'), ('emacs', 'back-to-indentation', 1, '0 5 0'), ('emacs', 'top-line', 1, '0 0 0'), ('emacs', 'middle-line', 1, '0 2 0'), ('emacs', 'bottom-line', 1, '0 5 0'), ('emacs', 'history-top', 1, '0 0 13'), ('emacs', 'history-bottom', 1, '0 5 0'), ('emacs', 'page-up', 1, '22 5 4'), ('emacs', 'page-down', 1, '0 5 0'), ('emacs', 'halfpage-up', 1, '1 5 3'), ('emacs', 'halfpage-down', 1, '0 5 0'), ('emacs', 'scroll-up', 1, '25 5 1'), ('emacs', 'scroll-down', 1, '0 5 0'), ('emacs', 'next-word', 1, '1 5 0'), ('emacs', 'next-word-end', 1, '1 5 0'), ('emacs', 'next-space', 1, '1 5 0'), ('emacs', 'next-space-end', 1, '1 5 0'), ('emacs', 'previous-word', 1, '23 4 0'), ('emacs', 'previous-space', 1, '23 4 0'), ('emacs', 'next-paragraph', 1, '0 5 0'), ('emacs', 'previous-paragraph', 1, '0 3 0'), ('emacs', 'scroll-middle', 1, '0 5 0'), ('emacs', 'scroll-top', 1, '0 5 0'), ('emacs', 'scroll-bottom', 1, '0 5 0'), ('emacs', 'cursor-centre-vertical', 1, '0 3 0'), ('emacs', 'jump-forward o', 1, '0 5 0'), ('emacs', 'jump-backward e', 1, '0 5 0'), ('emacs', 'jump-to-forward a', 1, '0 5 0'), ('emacs', 'jump-to-backward e', 1, '0 5 0'), ('emacs', 'goto-line 5', 1, '0 5 5'), ('emacs', 'search-forward ba', 1, '6 5 10'), ('emacs', 'search-backward ba', 1, '9 5 10'), ('emacs', 'search-forward (b', 1, '0 5 0'), ('emacs', 'search-backward to', 1, '5 4 13'), ('emacs', 'cursor-down', 3, '0 5 0'), ('emacs', 'cursor-right', 3, '0 5 0'), ('emacs', 'next-word', 3, '1 5 0'), ('emacs', 'previous-word', 3, '13 4 0'), ('emacs', 'next-word-end', 3, '1 5 0'), ('emacs', 'page-up', 3, '0 5 12'), ('emacs', 'scroll-up', 3, '1 5 3'), ('emacs', 'history-top;cursor-up', 1, '0 0 13'), ('emacs', 'history-top;cursor-down', 1, '0 1 13'), ('emacs', 'history-top;cursor-left', 1, '0 0 13'), ('emacs', 'history-top;cursor-right', 1, '1 0 13'), ('emacs', 'history-top;start-of-line', 1, '0 0 13'), ('emacs', 'history-top;end-of-line', 1, '23 0 13'), ('emacs', 'history-top;back-to-indentation', 1, '0 0 13'), ('emacs', 'history-top;top-line', 1, '0 0 13'), ('emacs', 'history-top;middle-line', 1, '0 2 13'), ('emacs', 'history-top;bottom-line', 1, '0 5 13'), ('emacs', 'history-top*2', 1, '0 0 13'), ('emacs', 'history-top;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;page-up', 1, '0 0 13'), ('emacs', 'history-top;page-down', 1, '0 0 9'), ('emacs', 'history-top;halfpage-up', 1, '0 0 13'), ('emacs', 'history-top;halfpage-down', 1, '0 0 10'), ('emacs', 'history-top;scroll-up', 1, '0 0 13'), ('emacs', 'history-top;scroll-down', 1, '0 0 12'), ('emacs', 'history-top;next-word', 1, '6 0 13'), ('emacs', 'history-top;next-word-end', 1, '5 0 13'), ('emacs', 'history-top;next-space', 1, '6 0 13'), ('emacs', 'history-top;next-space-end', 1, '5 0 13'), ('emacs', 'history-top;previous-word', 1, '0 0 13'), ('emacs', 'history-top;previous-space', 1, '0 0 13'), ('emacs', 'history-top;next-paragraph', 1, '0 2 13'), ('emacs', 'history-top;previous-paragraph', 1, '0 0 13'), ('emacs', 'history-top;scroll-middle', 1, '0 0 13'), ('emacs', 'history-top;scroll-top', 1, '0 0 13'), ('emacs', 'history-top;scroll-bottom', 1, '0 0 13'), ('emacs', 'history-top;cursor-centre-vertical', 1, '0 3 13'), ('emacs', 'history-top;jump-forward o', 1, '0 0 13'), ('emacs', 'history-top;jump-backward e', 1, '0 0 13'), ('emacs', 'history-top;jump-to-forward a', 1, '3 0 13'), ('emacs', 'history-top;jump-to-backward e', 1, '0 0 13'), ('emacs', 'history-top;goto-line 5', 1, '0 0 5'), ('emacs', 'history-top;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;search-forward (b', 1, '0 0 13'), ('emacs', 'history-top;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down', 3, '0 3 13'), ('emacs', 'history-top;cursor-right', 3, '3 0 13'), ('emacs', 'history-top;next-word', 3, '11 0 13'), ('emacs', 'history-top;previous-word', 3, '0 0 13'), ('emacs', 'history-top;next-word-end', 3, '11 0 13'), ('emacs', 'history-top;page-up', 3, '0 0 13'), ('emacs', 'history-top;scroll-up', 3, '0 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-up', 1, '0 2 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 1, '3 4 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-left', 1, '2 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*4', 1, '4 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;start-of-line', 1, '0 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;end-of-line', 1, '27 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;back-to-indentation', 1, '0 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;top-line', 1, '0 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;middle-line', 1, '0 2 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;bottom-line', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;history-top', 1, '0 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-up', 1, '3 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-down', 1, '3 3 9'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;halfpage-up', 1, '3 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;halfpage-down', 1, '0 3 10'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 1, '3 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-down', 1, '3 3 12'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word', 1, '4 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 1, '4 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-space', 1, '11 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-space-end', 1, '10 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-word', 1, '0 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-space', 1, '0 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-paragraph', 1, '0 5 12'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-paragraph', 1, '0 2 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-middle', 1, '3 2 12'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-top', 1, '3 0 10'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-bottom', 1, '3 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-centre-vertical', 1, '3 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-forward o', 1, '11 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-backward e', 1, '1 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-to-forward a', 1, '4 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-to-backward e', 1, '2 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;goto-line 5', 1, '3 3 5'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-forward (b', 1, '3 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 3, '0 5 12'), ('emacs', 'history-top;cursor-down*3;cursor-right*4', 3, '6 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word', 3, '11 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-word', 3, '20 1 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 3, '10 3 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-up', 3, '3 0 13'), ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 3, '3 3 13'), ('emacs', 'page-up;cursor-up', 1, '12 4 4'), ('emacs', 'page-up;cursor-down', 1, '1 5 3'), ('emacs', 'page-up;cursor-left', 1, '21 5 4'), ('emacs', 'page-up;cursor-right', 1, '0 5 3'), ('emacs', 'page-up;start-of-line', 1, '0 5 4'), ('emacs', 'page-up;end-of-line', 1, '22 5 4'), ('emacs', 'page-up;back-to-indentation', 1, '0 5 4'), ('emacs', 'page-up;top-line', 1, '0 0 4'), ('emacs', 'page-up;middle-line', 1, '0 2 4'), ('emacs', 'page-up;bottom-line', 1, '0 5 4'), ('emacs', 'page-up;history-top', 1, '0 0 13'), ('emacs', 'page-up;history-bottom', 1, '0 5 0'), ('emacs', 'page-up*2', 1, '0 5 8'), ('emacs', 'page-up;page-down', 1, '0 5 0'), ('emacs', 'page-up;halfpage-up', 1, '23 5 7'), ('emacs', 'page-up;halfpage-down', 1, '25 5 1'), ('emacs', 'page-up;scroll-up', 1, '12 5 5'), ('emacs', 'page-up;scroll-down', 1, '1 5 3'), ('emacs', 'page-up;next-word', 1, '0 5 3'), ('emacs', 'page-up;next-word-end', 1, '1 5 3'), ('emacs', 'page-up;next-space', 1, '0 5 3'), ('emacs', 'page-up;next-space-end', 1, '1 5 3'), ('emacs', 'page-up;previous-word', 1, '12 5 4'), ('emacs', 'page-up;previous-space', 1, '12 5 4'), ('emacs', 'page-up;next-paragraph', 1, '0 5 2'), ('emacs', 'page-up;previous-paragraph', 1, '0 1 4'), ('emacs', 'page-up;scroll-middle', 1, '22 2 1'), ('emacs', 'page-up;scroll-top', 1, '22 5 4'), ('emacs', 'page-up;scroll-bottom', 1, '22 5 4'), ('emacs', 'page-up;cursor-centre-vertical', 1, '22 3 4'), ('emacs', 'page-up;jump-forward o', 1, '22 5 4'), ('emacs', 'page-up;jump-backward e', 1, '21 5 4'), ('emacs', 'page-up;jump-to-forward a', 1, '22 5 4'), ('emacs', 'page-up;jump-to-backward e', 1, '16 5 4'), ('emacs', 'page-up;goto-line 5', 1, '22 5 5'), ('emacs', 'page-up;search-forward ba', 1, '6 5 10'), ('emacs', 'page-up;search-backward ba', 1, '9 5 10'), ('emacs', 'page-up;search-forward (b', 1, '22 5 4'), ('emacs', 'page-up;search-backward to', 1, '5 4 13'), ('emacs', 'page-up;cursor-down', 3, '25 5 1'), ('emacs', 'page-up;cursor-right', 3, '0 5 2'), ('emacs', 'page-up;next-word', 3, '5 5 1'), ('emacs', 'page-up;previous-word', 3, '0 5 4'), ('emacs', 'page-up;next-word-end', 3, '9 5 1'), ('emacs', 'page-up*2', 3, '27 1 13'), ('emacs', 'page-up;scroll-up', 3, '23 5 7'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-up', 1, '11 3 4'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-down', 1, '22 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-left', 1, '11 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-right', 1, '0 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;start-of-line', 1, '0 4 4'), ('emacs', 'page-up;cursor-up;end-of-line*2', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;back-to-indentation', 1, '0 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;top-line', 1, '0 0 4'), ('emacs', 'page-up;cursor-up;end-of-line;middle-line', 1, '0 2 4'), ('emacs', 'page-up;cursor-up;end-of-line;bottom-line', 1, '0 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;history-top', 1, '0 0 13'), ('emacs', 'page-up;cursor-up;end-of-line;history-bottom', 1, '0 5 0'), ('emacs', 'page-up;cursor-up;end-of-line;page-up', 1, '12 4 8'), ('emacs', 'page-up;cursor-up;end-of-line;page-down', 1, '25 4 0'), ('emacs', 'page-up;cursor-up;end-of-line;halfpage-up', 1, '0 4 7'), ('emacs', 'page-up;cursor-up;end-of-line;halfpage-down', 1, '0 4 1'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-up', 1, '11 4 5'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-down', 1, '22 4 3'), ('emacs', 'page-up;cursor-up;end-of-line;next-word', 1, '0 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-word-end', 1, '5 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-space', 1, '0 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-space-end', 1, '5 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;previous-word', 1, '8 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;previous-space', 1, '8 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-paragraph', 1, '0 5 2'), ('emacs', 'page-up;cursor-up;end-of-line;previous-paragraph', 1, '0 1 4'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-middle', 1, '12 2 2'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-top', 1, '12 0 0'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-bottom', 1, '12 5 5'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-centre-vertical', 1, '12 3 4'), ('emacs', 'page-up;cursor-up;end-of-line;jump-forward o', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;jump-backward e', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;jump-to-forward a', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;jump-to-backward e', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;goto-line 5', 1, '12 4 5'), ('emacs', 'page-up;cursor-up;end-of-line;search-forward ba', 1, '6 5 10'), ('emacs', 'page-up;cursor-up;end-of-line;search-backward ba', 1, '9 5 10'), ('emacs', 'page-up;cursor-up;end-of-line;search-forward (b', 1, '12 4 4'), ('emacs', 'page-up;cursor-up;end-of-line;search-backward to', 1, '5 4 13'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-down', 3, '0 5 2'), ('emacs', 'page-up;cursor-up;end-of-line;cursor-right', 3, '2 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-word', 3, '12 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;previous-word', 3, '10 3 4'), ('emacs', 'page-up;cursor-up;end-of-line;next-word-end', 3, '22 5 4'), ('emacs', 'page-up;cursor-up;end-of-line;page-up', 3, '23 0 13'), ('emacs', 'page-up;cursor-up;end-of-line;scroll-up', 3, '0 4 7'), ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-up', 1, '11 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-down', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-left', 1, '10 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*12', 1, '12 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;start-of-line', 1, '0 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;end-of-line', 1, '27 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;back-to-indentation', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;top-line', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;middle-line', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;bottom-line', 1, '0 5 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;history-top', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;cursor-down;cursor-right*11;page-up', 1, '11 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;page-down', 1, '5 1 9'), ('emacs', 'history-top;cursor-down;cursor-right*11;halfpage-up', 1, '11 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;halfpage-down', 1, '11 1 10'), ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-up', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-down', 1, '0 1 12'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-word', 1, '12 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-word-end', 1, '12 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-space', 1, '14 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-space-end', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;previous-word', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;previous-space', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-paragraph', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;previous-paragraph', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-middle', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-top', 1, '11 0 12'), ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-bottom', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-centre-vertical', 1, '11 3 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;jump-forward o', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;jump-backward e', 1, '8 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;jump-to-forward a', 1, '23 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;jump-to-backward e', 1, '9 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;goto-line 5', 1, '11 1 5'), ('emacs', 'history-top;cursor-down;cursor-right*11;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*11;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*11;search-forward (b', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;next-matching-bracket', 1, '21 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*11;previous-matching-bracket', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-up', 1, '20 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-down', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-left', 1, '19 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*21', 1, '21 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;start-of-line', 1, '0 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;end-of-line', 1, '27 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;back-to-indentation', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;top-line', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;middle-line', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;bottom-line', 1, '0 5 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;history-top', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;cursor-down;cursor-right*20;page-up', 1, '20 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;page-down', 1, '5 1 9'), ('emacs', 'history-top;cursor-down;cursor-right*20;halfpage-up', 1, '20 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;halfpage-down', 1, '17 1 10'), ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-up', 1, '20 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-down', 1, '0 1 12'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-word', 1, '23 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-word-end', 1, '22 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-space', 1, '23 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-space-end', 1, '22 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;previous-word', 1, '19 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;previous-space', 1, '18 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-paragraph', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;previous-paragraph', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-middle', 1, '20 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-top', 1, '20 0 12'), ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-bottom', 1, '20 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-centre-vertical', 1, '20 3 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;jump-forward o', 1, '20 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;jump-backward e', 1, '8 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;jump-to-forward a', 1, '23 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;jump-to-backward e', 1, '9 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;goto-line 5', 1, '20 1 5'), ('emacs', 'history-top;cursor-down;cursor-right*20;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*20;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*20;search-forward (b', 1, '20 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;next-matching-bracket', 1, '22 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*20;previous-matching-bracket', 1, '18 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-up', 1, '13 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-down', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-left', 1, '12 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*14', 1, '14 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;start-of-line', 1, '0 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;end-of-line', 1, '27 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;back-to-indentation', 1, '2 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;top-line', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;middle-line', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;bottom-line', 1, '0 5 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;history-top', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;cursor-down;cursor-right*13;page-up', 1, '13 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;page-down', 1, '5 1 9'), ('emacs', 'history-top;cursor-down;cursor-right*13;halfpage-up', 1, '13 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;halfpage-down', 1, '13 1 10'), ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-up', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-down', 1, '0 1 12'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-word', 1, '14 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-word-end', 1, '15 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-space', 1, '14 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-space-end', 1, '17 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;previous-word', 1, '12 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;previous-space', 1, '11 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-paragraph', 1, '0 2 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;previous-paragraph', 1, '0 0 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-middle', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-top', 1, '13 0 12'), ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-bottom', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-centre-vertical', 1, '13 3 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;jump-forward o', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;jump-backward e', 1, '8 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;jump-to-forward a', 1, '23 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;jump-to-backward e', 1, '9 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;goto-line 5', 1, '13 1 5'), ('emacs', 'history-top;cursor-down;cursor-right*13;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*13;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;cursor-down;cursor-right*13;search-forward (b', 1, '13 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;next-matching-bracket', 1, '16 1 13'), ('emacs', 'history-top;cursor-down;cursor-right*13;previous-matching-bracket', 1, '11 1 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-up', 1, '0 3 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-down', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-left', 1, '16 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-right', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;start-of-line', 1, '0 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line*2', 1, '17 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;back-to-indentation', 1, '0 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;top-line', 1, '0 0 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;middle-line', 1, '0 2 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;bottom-line', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;history-top', 1, '0 0 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;history-bottom', 1, '0 5 0'), ('emacs', 'history-top;cursor-down*4;end-of-line;page-up', 1, '0 0 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;page-down', 1, '0 4 9'), ('emacs', 'history-top;cursor-down*4;end-of-line;halfpage-up', 1, '0 1 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;halfpage-down', 1, '0 4 10'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-up', 1, '0 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-down', 1, '0 4 12'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-word', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-word-end', 1, '5 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-space', 1, '0 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-space-end', 1, '5 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;previous-word', 1, '16 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;previous-space', 1, '16 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-paragraph', 1, '0 5 12'), ('emacs', 'history-top;cursor-down*4;end-of-line;previous-paragraph', 1, '0 2 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-middle', 1, '17 2 11'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-top', 1, '17 0 9'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-bottom', 1, '17 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-centre-vertical', 1, '17 3 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;jump-forward o', 1, '17 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;jump-backward e', 1, '11 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;jump-to-forward a', 1, '17 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;jump-to-backward e', 1, '12 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;goto-line 5', 1, '17 4 5'), ('emacs', 'history-top;cursor-down*4;end-of-line;search-forward ba', 1, '6 5 10'), ('emacs', 'history-top;cursor-down*4;end-of-line;search-backward ba', 1, '9 5 10'), ('emacs', 'history-top;cursor-down*4;end-of-line;search-forward (b', 1, '17 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;search-backward to', 1, '5 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-down', 3, '0 5 11'), ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-right', 3, '2 5 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-word', 3, '12 5 11'), ('emacs', 'history-top;cursor-down*4;end-of-line;previous-word', 3, '12 4 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;next-word-end', 3, '17 5 11'), ('emacs', 'history-top;cursor-down*4;end-of-line;page-up', 3, '0 0 13'), ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-up', 3, '0 4 13'), ('vi', 'cursor-up', 1, '25 4 0'), ('vi', 'cursor-down', 1, '0 5 0'), ('vi', 'cursor-left', 1, '25 4 0'), ('vi', 'cursor-right', 1, '0 5 0'), ('vi', 'start-of-line', 1, '0 5 0'), ('vi', 'end-of-line', 1, '0 5 0'), ('vi', 'back-to-indentation', 1, '0 5 0'), ('vi', 'top-line', 1, '0 0 0'), ('vi', 'middle-line', 1, '0 2 0'), ('vi', 'bottom-line', 1, '0 5 0'), ('vi', 'history-top', 1, '0 0 13'), ('vi', 'history-bottom', 1, '0 5 0'), ('vi', 'page-up', 1, '22 5 4'), ('vi', 'page-down', 1, '0 5 0'), ('vi', 'halfpage-up', 1, '1 5 3'), ('vi', 'halfpage-down', 1, '0 5 0'), ('vi', 'scroll-up', 1, '25 5 1'), ('vi', 'scroll-down', 1, '0 5 0'), ('vi', 'next-word', 1, '1 5 0'), ('vi', 'next-word-end', 1, '0 5 0'), ('vi', 'next-space', 1, '1 5 0'), ('vi', 'next-space-end', 1, '0 5 0'), ('vi', 'previous-word', 1, '23 4 0'), ('vi', 'previous-space', 1, '23 4 0'), ('vi', 'next-paragraph', 1, '0 5 0'), ('vi', 'previous-paragraph', 1, '0 3 0'), ('vi', 'scroll-middle', 1, '0 5 0'), ('vi', 'scroll-top', 1, '0 5 0'), ('vi', 'scroll-bottom', 1, '0 5 0'), ('vi', 'cursor-centre-vertical', 1, '0 3 0'), ('vi', 'jump-forward o', 1, '0 5 0'), ('vi', 'jump-backward e', 1, '0 5 0'), ('vi', 'jump-to-forward a', 1, '0 5 0'), ('vi', 'jump-to-backward e', 1, '0 5 0'), ('vi', 'goto-line 5', 1, '0 5 5'), ('vi', 'search-forward ba', 1, '4 5 10'), ('vi', 'search-backward ba', 1, '9 5 10'), ('vi', 'search-forward (b', 1, '0 5 0'), ('vi', 'search-backward to', 1, '5 4 13'), ('vi', 'cursor-down', 3, '0 5 0'), ('vi', 'cursor-right', 3, '0 5 0'), ('vi', 'next-word', 3, '1 5 0'), ('vi', 'previous-word', 3, '13 4 0'), ('vi', 'next-word-end', 3, '0 5 0'), ('vi', 'page-up', 3, '0 5 12'), ('vi', 'scroll-up', 3, '1 5 3'), ('vi', 'history-top;cursor-up', 1, '0 0 13'), ('vi', 'history-top;cursor-down', 1, '0 1 13'), ('vi', 'history-top;cursor-left', 1, '0 0 13'), ('vi', 'history-top;cursor-right', 1, '1 0 13'), ('vi', 'history-top;start-of-line', 1, '0 0 13'), ('vi', 'history-top;end-of-line', 1, '23 0 13'), ('vi', 'history-top;back-to-indentation', 1, '0 0 13'), ('vi', 'history-top;top-line', 1, '0 0 13'), ('vi', 'history-top;middle-line', 1, '0 2 13'), ('vi', 'history-top;bottom-line', 1, '0 5 13'), ('vi', 'history-top*2', 1, '0 0 13'), ('vi', 'history-top;history-bottom', 1, '0 5 0'), ('vi', 'history-top;page-up', 1, '0 0 13'), ('vi', 'history-top;page-down', 1, '0 0 9'), ('vi', 'history-top;halfpage-up', 1, '0 0 13'), ('vi', 'history-top;halfpage-down', 1, '0 0 10'), ('vi', 'history-top;scroll-up', 1, '0 0 13'), ('vi', 'history-top;scroll-down', 1, '0 0 12'), ('vi', 'history-top;next-word', 1, '6 0 13'), ('vi', 'history-top;next-word-end', 1, '4 0 13'), ('vi', 'history-top;next-space', 1, '6 0 13'), ('vi', 'history-top;next-space-end', 1, '4 0 13'), ('vi', 'history-top;previous-word', 1, '0 0 13'), ('vi', 'history-top;previous-space', 1, '0 0 13'), ('vi', 'history-top;next-paragraph', 1, '0 2 13'), ('vi', 'history-top;previous-paragraph', 1, '0 0 13'), ('vi', 'history-top;scroll-middle', 1, '0 0 13'), ('vi', 'history-top;scroll-top', 1, '0 0 13'), ('vi', 'history-top;scroll-bottom', 1, '0 0 13'), ('vi', 'history-top;cursor-centre-vertical', 1, '0 3 13'), ('vi', 'history-top;jump-forward o', 1, '0 0 13'), ('vi', 'history-top;jump-backward e', 1, '0 0 13'), ('vi', 'history-top;jump-to-forward a', 1, '3 0 13'), ('vi', 'history-top;jump-to-backward e', 1, '0 0 13'), ('vi', 'history-top;goto-line 5', 1, '0 0 5'), ('vi', 'history-top;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;search-forward (b', 1, '0 0 13'), ('vi', 'history-top;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down', 3, '0 3 13'), ('vi', 'history-top;cursor-right', 3, '3 0 13'), ('vi', 'history-top;next-word', 3, '11 0 13'), ('vi', 'history-top;previous-word', 3, '0 0 13'), ('vi', 'history-top;next-word-end', 3, '10 0 13'), ('vi', 'history-top;page-up', 3, '0 0 13'), ('vi', 'history-top;scroll-up', 3, '0 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-up', 1, '0 2 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 1, '3 4 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-left', 1, '2 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*4', 1, '4 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;start-of-line', 1, '0 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;end-of-line', 1, '27 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;back-to-indentation', 1, '0 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;top-line', 1, '0 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;middle-line', 1, '0 2 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;bottom-line', 1, '0 5 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;history-top', 1, '0 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;history-bottom', 1, '0 5 0'), ('vi', 'history-top;cursor-down*3;cursor-right*3;page-up', 1, '3 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;page-down', 1, '3 3 9'), ('vi', 'history-top;cursor-down*3;cursor-right*3;halfpage-up', 1, '3 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;halfpage-down', 1, '0 3 10'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 1, '3 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-down', 1, '3 3 12'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word', 1, '4 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 1, '8 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-space', 1, '11 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-space-end', 1, '9 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-word', 1, '0 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-space', 1, '0 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-paragraph', 1, '0 5 12'), ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-paragraph', 1, '0 2 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-middle', 1, '3 2 12'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-top', 1, '3 0 10'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-bottom', 1, '3 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-centre-vertical', 1, '3 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-forward o', 1, '11 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-backward e', 1, '1 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-to-forward a', 1, '4 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-to-backward e', 1, '2 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;goto-line 5', 1, '3 3 5'), ('vi', 'history-top;cursor-down*3;cursor-right*3;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;cursor-down*3;cursor-right*3;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;cursor-down*3;cursor-right*3;search-forward (b', 1, '3 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 3, '0 5 12'), ('vi', 'history-top;cursor-down*3;cursor-right*4', 3, '6 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word', 3, '11 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-word', 3, '20 1 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 3, '19 3 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;page-up', 3, '3 0 13'), ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 3, '3 3 13'), ('vi', 'page-up;cursor-up', 1, '12 4 4'), ('vi', 'page-up;cursor-down', 1, '1 5 3'), ('vi', 'page-up;cursor-left', 1, '21 5 4'), ('vi', 'page-up;cursor-right', 1, '0 5 3'), ('vi', 'page-up;start-of-line', 1, '0 5 4'), ('vi', 'page-up;end-of-line', 1, '22 5 4'), ('vi', 'page-up;back-to-indentation', 1, '0 5 4'), ('vi', 'page-up;top-line', 1, '0 0 4'), ('vi', 'page-up;middle-line', 1, '0 2 4'), ('vi', 'page-up;bottom-line', 1, '0 5 4'), ('vi', 'page-up;history-top', 1, '0 0 13'), ('vi', 'page-up;history-bottom', 1, '0 5 0'), ('vi', 'page-up*2', 1, '0 5 8'), ('vi', 'page-up;page-down', 1, '0 5 0'), ('vi', 'page-up;halfpage-up', 1, '23 5 7'), ('vi', 'page-up;halfpage-down', 1, '25 5 1'), ('vi', 'page-up;scroll-up', 1, '12 5 5'), ('vi', 'page-up;scroll-down', 1, '1 5 3'), ('vi', 'page-up;next-word', 1, '0 5 3'), ('vi', 'page-up;next-word-end', 1, '0 5 3'), ('vi', 'page-up;next-space', 1, '0 5 3'), ('vi', 'page-up;next-space-end', 1, '0 5 3'), ('vi', 'page-up;previous-word', 1, '12 5 4'), ('vi', 'page-up;previous-space', 1, '12 5 4'), ('vi', 'page-up;next-paragraph', 1, '0 5 2'), ('vi', 'page-up;previous-paragraph', 1, '0 1 4'), ('vi', 'page-up;scroll-middle', 1, '22 2 1'), ('vi', 'page-up;scroll-top', 1, '22 5 4'), ('vi', 'page-up;scroll-bottom', 1, '22 5 4'), ('vi', 'page-up;cursor-centre-vertical', 1, '22 3 4'), ('vi', 'page-up;jump-forward o', 1, '22 5 4'), ('vi', 'page-up;jump-backward e', 1, '21 5 4'), ('vi', 'page-up;jump-to-forward a', 1, '22 5 4'), ('vi', 'page-up;jump-to-backward e', 1, '16 5 4'), ('vi', 'page-up;goto-line 5', 1, '22 5 5'), ('vi', 'page-up;search-forward ba', 1, '4 5 10'), ('vi', 'page-up;search-backward ba', 1, '9 5 10'), ('vi', 'page-up;search-forward (b', 1, '22 5 4'), ('vi', 'page-up;search-backward to', 1, '5 4 13'), ('vi', 'page-up;cursor-down', 3, '25 5 1'), ('vi', 'page-up;cursor-right', 3, '0 5 2'), ('vi', 'page-up;next-word', 3, '5 5 1'), ('vi', 'page-up;previous-word', 3, '0 5 4'), ('vi', 'page-up;next-word-end', 3, '8 5 1'), ('vi', 'page-up*2', 3, '27 1 13'), ('vi', 'page-up;scroll-up', 3, '23 5 7'), ('vi', 'page-up;cursor-up;end-of-line;cursor-up', 1, '11 3 4'), ('vi', 'page-up;cursor-up;end-of-line;cursor-down', 1, '22 5 4'), ('vi', 'page-up;cursor-up;end-of-line;cursor-left', 1, '11 4 4'), ('vi', 'page-up;cursor-up;end-of-line;cursor-right', 1, '0 5 4'), ('vi', 'page-up;cursor-up;end-of-line;start-of-line', 1, '0 4 4'), ('vi', 'page-up;cursor-up;end-of-line*2', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;back-to-indentation', 1, '0 4 4'), ('vi', 'page-up;cursor-up;end-of-line;top-line', 1, '0 0 4'), ('vi', 'page-up;cursor-up;end-of-line;middle-line', 1, '0 2 4'), ('vi', 'page-up;cursor-up;end-of-line;bottom-line', 1, '0 5 4'), ('vi', 'page-up;cursor-up;end-of-line;history-top', 1, '0 0 13'), ('vi', 'page-up;cursor-up;end-of-line;history-bottom', 1, '0 5 0'), ('vi', 'page-up;cursor-up;end-of-line;page-up', 1, '12 4 8'), ('vi', 'page-up;cursor-up;end-of-line;page-down', 1, '25 4 0'), ('vi', 'page-up;cursor-up;end-of-line;halfpage-up', 1, '0 4 7'), ('vi', 'page-up;cursor-up;end-of-line;halfpage-down', 1, '0 4 1'), ('vi', 'page-up;cursor-up;end-of-line;scroll-up', 1, '11 4 5'), ('vi', 'page-up;cursor-up;end-of-line;scroll-down', 1, '22 4 3'), ('vi', 'page-up;cursor-up;end-of-line;next-word', 1, '0 5 4'), ('vi', 'page-up;cursor-up;end-of-line;next-word-end', 1, '4 5 4'), ('vi', 'page-up;cursor-up;end-of-line;next-space', 1, '0 5 4'), ('vi', 'page-up;cursor-up;end-of-line;next-space-end', 1, '4 5 4'), ('vi', 'page-up;cursor-up;end-of-line;previous-word', 1, '8 4 4'), ('vi', 'page-up;cursor-up;end-of-line;previous-space', 1, '8 4 4'), ('vi', 'page-up;cursor-up;end-of-line;next-paragraph', 1, '0 5 2'), ('vi', 'page-up;cursor-up;end-of-line;previous-paragraph', 1, '0 1 4'), ('vi', 'page-up;cursor-up;end-of-line;scroll-middle', 1, '12 2 2'), ('vi', 'page-up;cursor-up;end-of-line;scroll-top', 1, '12 0 0'), ('vi', 'page-up;cursor-up;end-of-line;scroll-bottom', 1, '12 5 5'), ('vi', 'page-up;cursor-up;end-of-line;cursor-centre-vertical', 1, '12 3 4'), ('vi', 'page-up;cursor-up;end-of-line;jump-forward o', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;jump-backward e', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;jump-to-forward a', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;jump-to-backward e', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;goto-line 5', 1, '12 4 5'), ('vi', 'page-up;cursor-up;end-of-line;search-forward ba', 1, '4 5 10'), ('vi', 'page-up;cursor-up;end-of-line;search-backward ba', 1, '9 5 10'), ('vi', 'page-up;cursor-up;end-of-line;search-forward (b', 1, '12 4 4'), ('vi', 'page-up;cursor-up;end-of-line;search-backward to', 1, '5 4 13'), ('vi', 'page-up;cursor-up;end-of-line;cursor-down', 3, '0 5 2'), ('vi', 'page-up;cursor-up;end-of-line;cursor-right', 3, '2 5 4'), ('vi', 'page-up;cursor-up;end-of-line;next-word', 3, '12 5 4'), ('vi', 'page-up;cursor-up;end-of-line;previous-word', 3, '10 3 4'), ('vi', 'page-up;cursor-up;end-of-line;next-word-end', 3, '21 5 4'), ('vi', 'page-up;cursor-up;end-of-line;page-up', 3, '23 0 13'), ('vi', 'page-up;cursor-up;end-of-line;scroll-up', 3, '0 4 7'), ('vi', 'history-top;cursor-down;cursor-right*11;cursor-up', 1, '11 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;cursor-down', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*11;cursor-left', 1, '10 1 13'), ('vi', 'history-top;cursor-down;cursor-right*12', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;start-of-line', 1, '0 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;end-of-line', 1, '27 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;back-to-indentation', 1, '2 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;top-line', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;middle-line', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*11;bottom-line', 1, '0 5 13'), ('vi', 'history-top;cursor-down;cursor-right*11;history-top', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;history-bottom', 1, '0 5 0'), ('vi', 'history-top;cursor-down;cursor-right*11;page-up', 1, '11 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;page-down', 1, '5 1 9'), ('vi', 'history-top;cursor-down;cursor-right*11;halfpage-up', 1, '11 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;halfpage-down', 1, '11 1 10'), ('vi', 'history-top;cursor-down;cursor-right*11;scroll-up', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;scroll-down', 1, '0 1 12'), ('vi', 'history-top;cursor-down;cursor-right*11;next-word', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;next-word-end', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;next-space', 1, '14 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;next-space-end', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;previous-word', 1, '2 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;previous-space', 1, '2 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;next-paragraph', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*11;previous-paragraph', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*11;scroll-middle', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;scroll-top', 1, '11 0 12'), ('vi', 'history-top;cursor-down;cursor-right*11;scroll-bottom', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;cursor-centre-vertical', 1, '11 3 13'), ('vi', 'history-top;cursor-down;cursor-right*11;jump-forward o', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;jump-backward e', 1, '8 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;jump-to-forward a', 1, '23 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;jump-to-backward e', 1, '9 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;goto-line 5', 1, '11 1 5'), ('vi', 'history-top;cursor-down;cursor-right*11;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;cursor-down;cursor-right*11;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;cursor-down;cursor-right*11;search-forward (b', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down;cursor-right*11;next-matching-bracket', 1, '21 1 13'), ('vi', 'history-top;cursor-down;cursor-right*11;previous-matching-bracket', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;cursor-up', 1, '20 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;cursor-down', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*20;cursor-left', 1, '19 1 13'), ('vi', 'history-top;cursor-down;cursor-right*21', 1, '21 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;start-of-line', 1, '0 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;end-of-line', 1, '27 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;back-to-indentation', 1, '2 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;top-line', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;middle-line', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*20;bottom-line', 1, '0 5 13'), ('vi', 'history-top;cursor-down;cursor-right*20;history-top', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;history-bottom', 1, '0 5 0'), ('vi', 'history-top;cursor-down;cursor-right*20;page-up', 1, '20 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;page-down', 1, '5 1 9'), ('vi', 'history-top;cursor-down;cursor-right*20;halfpage-up', 1, '20 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;halfpage-down', 1, '17 1 10'), ('vi', 'history-top;cursor-down;cursor-right*20;scroll-up', 1, '20 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;scroll-down', 1, '0 1 12'), ('vi', 'history-top;cursor-down;cursor-right*20;next-word', 1, '23 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;next-word-end', 1, '21 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;next-space', 1, '23 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;next-space-end', 1, '21 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;previous-word', 1, '19 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;previous-space', 1, '18 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;next-paragraph', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*20;previous-paragraph', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*20;scroll-middle', 1, '20 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;scroll-top', 1, '20 0 12'), ('vi', 'history-top;cursor-down;cursor-right*20;scroll-bottom', 1, '20 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;cursor-centre-vertical', 1, '20 3 13'), ('vi', 'history-top;cursor-down;cursor-right*20;jump-forward o', 1, '20 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;jump-backward e', 1, '8 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;jump-to-forward a', 1, '23 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;jump-to-backward e', 1, '9 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;goto-line 5', 1, '20 1 5'), ('vi', 'history-top;cursor-down;cursor-right*20;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;cursor-down;cursor-right*20;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;cursor-down;cursor-right*20;search-forward (b', 1, '20 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down;cursor-right*20;next-matching-bracket', 1, '18 1 13'), ('vi', 'history-top;cursor-down;cursor-right*20;previous-matching-bracket', 1, '18 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;cursor-up', 1, '13 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;cursor-down', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*13;cursor-left', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*14', 1, '14 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;start-of-line', 1, '0 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;end-of-line', 1, '27 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;back-to-indentation', 1, '2 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;top-line', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;middle-line', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*13;bottom-line', 1, '0 5 13'), ('vi', 'history-top;cursor-down;cursor-right*13;history-top', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;history-bottom', 1, '0 5 0'), ('vi', 'history-top;cursor-down;cursor-right*13;page-up', 1, '13 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;page-down', 1, '5 1 9'), ('vi', 'history-top;cursor-down;cursor-right*13;halfpage-up', 1, '13 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;halfpage-down', 1, '13 1 10'), ('vi', 'history-top;cursor-down;cursor-right*13;scroll-up', 1, '13 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;scroll-down', 1, '0 1 12'), ('vi', 'history-top;cursor-down;cursor-right*13;next-word', 1, '14 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;next-word-end', 1, '14 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;next-space', 1, '14 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;next-space-end', 1, '16 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;previous-word', 1, '12 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;previous-space', 1, '11 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;next-paragraph', 1, '0 2 13'), ('vi', 'history-top;cursor-down;cursor-right*13;previous-paragraph', 1, '0 0 13'), ('vi', 'history-top;cursor-down;cursor-right*13;scroll-middle', 1, '13 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;scroll-top', 1, '13 0 12'), ('vi', 'history-top;cursor-down;cursor-right*13;scroll-bottom', 1, '13 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;cursor-centre-vertical', 1, '13 3 13'), ('vi', 'history-top;cursor-down;cursor-right*13;jump-forward o', 1, '13 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;jump-backward e', 1, '8 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;jump-to-forward a', 1, '23 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;jump-to-backward e', 1, '9 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;goto-line 5', 1, '13 1 5'), ('vi', 'history-top;cursor-down;cursor-right*13;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;cursor-down;cursor-right*13;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;cursor-down;cursor-right*13;search-forward (b', 1, '13 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down;cursor-right*13;next-matching-bracket', 1, '16 1 13'), ('vi', 'history-top;cursor-down;cursor-right*13;previous-matching-bracket', 1, '13 1 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-up', 1, '0 3 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-down', 1, '0 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-left', 1, '16 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-right', 1, '0 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;start-of-line', 1, '0 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line*2', 1, '17 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;back-to-indentation', 1, '0 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;top-line', 1, '0 0 13'), ('vi', 'history-top;cursor-down*4;end-of-line;middle-line', 1, '0 2 13'), ('vi', 'history-top;cursor-down*4;end-of-line;bottom-line', 1, '0 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;history-top', 1, '0 0 13'), ('vi', 'history-top;cursor-down*4;end-of-line;history-bottom', 1, '0 5 0'), ('vi', 'history-top;cursor-down*4;end-of-line;page-up', 1, '0 0 13'), ('vi', 'history-top;cursor-down*4;end-of-line;page-down', 1, '0 4 9'), ('vi', 'history-top;cursor-down*4;end-of-line;halfpage-up', 1, '0 1 13'), ('vi', 'history-top;cursor-down*4;end-of-line;halfpage-down', 1, '0 4 10'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-up', 1, '0 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-down', 1, '0 4 12'), ('vi', 'history-top;cursor-down*4;end-of-line;next-word', 1, '0 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-word-end', 1, '4 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-space', 1, '0 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-space-end', 1, '4 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;previous-word', 1, '16 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;previous-space', 1, '16 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-paragraph', 1, '0 5 12'), ('vi', 'history-top;cursor-down*4;end-of-line;previous-paragraph', 1, '0 2 13'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-middle', 1, '17 2 11'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-top', 1, '17 0 9'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-bottom', 1, '17 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-centre-vertical', 1, '17 3 13'), ('vi', 'history-top;cursor-down*4;end-of-line;jump-forward o', 1, '17 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;jump-backward e', 1, '11 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;jump-to-forward a', 1, '17 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;jump-to-backward e', 1, '12 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;goto-line 5', 1, '17 4 5'), ('vi', 'history-top;cursor-down*4;end-of-line;search-forward ba', 1, '4 5 10'), ('vi', 'history-top;cursor-down*4;end-of-line;search-backward ba', 1, '9 5 10'), ('vi', 'history-top;cursor-down*4;end-of-line;search-forward (b', 1, '17 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;search-backward to', 1, '5 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-down', 3, '0 5 11'), ('vi', 'history-top;cursor-down*4;end-of-line;cursor-right', 3, '2 5 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-word', 3, '12 5 11'), ('vi', 'history-top;cursor-down*4;end-of-line;previous-word', 3, '12 4 13'), ('vi', 'history-top;cursor-down*4;end-of-line;next-word-end', 3, '16 5 11'), ('vi', 'history-top;cursor-down*4;end-of-line;page-up', 3, '0 0 13'), ('vi', 'history-top;cursor-down*4;end-of-line;scroll-up', 3, '0 4 13'), ) MPLEX_TMUX_SELECTIONS = ( ('emacs', 'begin-selection;cursor-right*2;copy-selection-and-cancel', 'al'), ('emacs', 'begin-selection;copy-selection-and-cancel', ''), ('emacs', 'begin-selection;next-word;copy-selection-and-cancel', 'alpha '), ('emacs', 'cursor-right*4;begin-selection;next-word-end;copy-selection-and-cancel', 'a'), ('emacs', 'begin-selection;end-of-line;copy-selection-and-cancel', 'alpha beta-gamma delta'), ('emacs', 'begin-selection;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), ('emacs', 'cursor-right*4;begin-selection;cursor-down*2;copy-selection-and-cancel', 'a beta-gamma delta\n indented (a [b] {c}) tail\n'), ('emacs', 'select-line;copy-selection-and-cancel', 'alpha beta-gamma delta'), ('emacs', 'select-line;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail'), ('emacs', 'cursor-right*5;select-word;copy-selection-and-cancel', 'alpha'), ('emacs', 'select-word;copy-selection-and-cancel', 'alpha'), ('emacs', 'cursor-right*4;copy-end-of-line-and-cancel', 'a beta-gamma delta'), ('emacs', 'copy-line-and-cancel', 'alpha beta-gamma delta'), ('emacs', 'cursor-down*2;copy-line-and-cancel', ''), ('emacs', 'cursor-down*2;copy-end-of-line-and-cancel', ''), ('emacs', 'cursor-right*2;begin-selection;rectangle-toggle;cursor-down;cursor-right*2;copy-selection-and-cancel', 'ph\nin'), ('emacs', 'cursor-right*8;begin-selection;cursor-left*4;copy-selection-and-cancel', 'a be'), ('emacs', 'cursor-down;cursor-right*5;begin-selection;cursor-up;copy-selection-and-cancel', ' beta-gamma delta\n ind'), ('emacs', 'cursor-right*2;begin-selection;cursor-right*2;other-end;cursor-left;copy-selection-and-cancel', 'lph'), ('emacs', 'begin-selection;next-word*3;copy-selection-and-cancel', 'alpha beta-'), ('emacs', 'cursor-down*2;begin-selection;cursor-down*3;copy-selection-and-cancel', '\nkey=value; other_one, last.\npath/to/file.py x\n'), ('emacs', 'page-up;begin-selection;page-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n\nkey=value; other_one, last.\n'), ('vi', 'begin-selection;cursor-right*2;copy-selection-and-cancel', 'alp'), ('vi', 'begin-selection;copy-selection-and-cancel', 'a'), ('vi', 'begin-selection;next-word;copy-selection-and-cancel', 'alpha b'), ('vi', 'cursor-right*4;begin-selection;next-word-end;copy-selection-and-cancel', 'a beta'), ('vi', 'begin-selection;end-of-line;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), ('vi', 'begin-selection;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n '), ('vi', 'cursor-right*4;begin-selection;cursor-down*2;copy-selection-and-cancel', 'a beta-gamma delta\n indented (a [b] {c}) tail\n\n'), ('vi', 'select-line;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), ('vi', 'select-line;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n'), ('vi', 'cursor-right*5;select-word;copy-selection-and-cancel', 'alpha'), ('vi', 'select-word;copy-selection-and-cancel', 'alpha'), ('vi', 'cursor-right*4;copy-end-of-line-and-cancel', 'a beta-gamma delta\n'), ('vi', 'copy-line-and-cancel', 'alpha beta-gamma delta\n'), ('vi', 'cursor-down*2;copy-line-and-cancel', '\n'), ('vi', 'cursor-down*2;copy-end-of-line-and-cancel', '\n'), ('vi', 'cursor-right*2;begin-selection;rectangle-toggle;cursor-down;cursor-right*2;copy-selection-and-cancel', 'pha\nind'), ('vi', 'cursor-right*8;begin-selection;cursor-left*4;copy-selection-and-cancel', 'a bet'), ('vi', 'cursor-down;cursor-right*5;begin-selection;cursor-up;copy-selection-and-cancel', ' beta-gamma delta\n inde'), ('vi', 'cursor-right*2;begin-selection;cursor-right*2;other-end;cursor-left;copy-selection-and-cancel', 'lpha'), ('vi', 'begin-selection;next-word*3;copy-selection-and-cancel', 'alpha beta-g'), ('vi', 'cursor-down*2;begin-selection;cursor-down*3;copy-selection-and-cancel', '\nkey=value; other_one, last.\npath/to/file.py x\ns'), ('vi', 'page-up;begin-selection;page-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n\nkey=value; other_one, last.\np'), ) MPLEX_TMUX_CONTENT = ['alpha beta-gamma delta', ' indented (a [b] {c}) tail', '', 'key=value; other_one, last.', 'path/to/file.py x', 'short', '', ' spaced words here', 'foo(bar) baz[qux] {z}', 'end of block', '', 'one two three four five', 'a-b c-d e-f', 'tab\tword', 'UPPER lower Mixed_Case', 'x', '', 'last line of text here ok'] class MplexCopyModeTests(unittest.TestCase): def make(self, text, rows=5, cols=20, history=50, keys="emacs"): pane = tai.MplexPane(1, cols, rows, history, tempfile.gettempdir()) pane.feed(text.replace("\n", "\r\n").encode("utf-8")) server = MplexStubServer() server.options.set("mode-keys", keys) mode = tai.MplexCopyMode(server, pane) pane.push_mode(mode) return server, pane, mode def run_cmd(self, mode, name, *words, count=1): mode.pane.prefix_count = count mode.command(name, list(words), None) def test_cursor_stands_between_characters_and_remembers_its_column(self): server, pane, mode = self.make("alpha beta\nab\nlonger line here", rows=6, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "cursor-right", count=3) self.run_cmd(mode, "cursor-down") self.assertEqual((mode.cx, mode.abs_y()), (2, 1)) self.run_cmd(mode, "cursor-down") self.assertEqual((mode.cx, mode.abs_y()), (3, 2)) self.run_cmd(mode, "end-of-line") self.assertEqual(mode.cx, len("longer line here")) self.run_cmd(mode, "cursor-right") self.assertEqual((mode.cx, mode.abs_y()), (0, 3)) self.run_cmd(mode, "history-top") self.run_cmd(mode, "end-of-line") self.run_cmd(mode, "cursor-right") self.assertEqual((mode.cx, mode.abs_y()), (0, 1)) self.run_cmd(mode, "cursor-left") self.assertEqual((mode.cx, mode.abs_y()), (10, 0)) def test_search_cursor_placement_differs_between_emacs_and_vi(self): for keys, first, again in (("emacs", (5, 0), (16, 0)), ("vi", (11, 0), (6, 1))): server, pane, mode = self.make("alpha beta alpha\ngamma alpha", rows=6, cols=30, keys=keys) self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-forward", "alpha") self.assertEqual((mode.cx, mode.abs_y()), first, keys) self.run_cmd(mode, "search-again") self.assertEqual((mode.cx, mode.abs_y()), again, keys) def test_backward_search_lands_on_the_match_start_and_wraps(self): server, pane, mode = self.make("alpha beta alpha\ngamma alpha", rows=6, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-backward", "alpha") self.assertEqual((mode.cx, mode.abs_y()), (6, 1)) self.run_cmd(mode, "search-again") self.assertEqual((mode.cx, mode.abs_y()), (11, 0)) def test_search_string_survives_copy_mode_and_restarts_upward(self): server, pane, mode = self.make("one two one two", rows=4, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-forward", "two") mode.close() fresh = tai.MplexCopyMode(server, pane) pane.push_mode(fresh) self.assertEqual(fresh.search[:2], ("two", False)) def test_smart_case_and_failed_search(self): server, pane, mode = self.make("Apple\napple\nAPPLE", rows=4, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-forward", "APPLE") self.assertEqual(mode.abs_y(), 2) self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-forward", "apple") self.assertEqual(mode.abs_y(), 0) self.run_cmd(mode, "search-forward", "zebra") self.run_cmd(mode, "search-forward", "(unbalanced") self.assertEqual(server.messages, ["Search failed", "Search failed"]) self.assertEqual(mode.abs_y(), 0) def test_select_word_on_blanks_takes_the_previous_word(self): server, pane, mode = self.make("alpha beta", rows=4, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "cursor-right", count=5) self.run_cmd(mode, "select-word") self.assertEqual(mode.selection_text(), "alpha") def test_selection_end_is_exclusive_for_emacs_and_inclusive_for_vi(self): for keys, expect in (("emacs", "alp"), ("vi", "alph")): server, pane, mode = self.make("alpha beta", rows=4, cols=30, keys=keys) self.run_cmd(mode, "history-top") self.run_cmd(mode, "begin-selection") self.run_cmd(mode, "cursor-right", count=3) self.assertEqual(mode.selection_text(), expect, keys) def test_copy_modes_clear_or_end_the_selection(self): server, pane, mode = self.make("alpha beta\ngamma", rows=4, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "begin-selection") self.run_cmd(mode, "next-word") self.run_cmd(mode, "copy-selection") self.assertEqual(server.copied[-1][0], "alpha ") self.assertIsNone(mode.anchor) self.assertIn(mode, pane.modes) self.run_cmd(mode, "select-line") self.run_cmd(mode, "copy-selection-and-cancel") self.assertEqual(server.copied[-1][0], "alpha beta") self.assertNotIn(mode, pane.modes) def test_pipe_command_travels_with_the_copy(self): server, pane, mode = self.make("alpha beta", rows=4, cols=30) self.run_cmd(mode, "history-top") self.run_cmd(mode, "copy-pipe-line", "xclip -i") self.assertEqual(server.copied[-1], ("alpha beta", "xclip -i", False)) def test_repeat_count_applies_once(self): server, pane, mode = self.make("\n".join(str(n) for n in range(10)), rows=10) self.run_cmd(mode, "history-top") self.run_cmd(mode, "cursor-down", count=4) self.assertEqual(mode.abs_y(), 4) self.run_cmd(mode, "cursor-down") self.assertEqual(mode.abs_y(), 5) def test_output_while_scrolled_keeps_the_view(self): server, pane, mode = self.make("\n".join("row%d" % n for n in range(12)), rows=5) self.run_cmd(mode, "page-up") top_before = mode.text(mode.top()) pane.feed(b"\r\nnew1\r\nnew2\r\n") self.assertEqual(mode.text(mode.top()), top_before) self.run_cmd(mode, "history-bottom") self.assertEqual(mode.oy, 0) def test_render_marks_selection_and_position(self): server, pane, mode = self.make("alpha\nbeta", rows=3, cols=12) self.run_cmd(mode, "history-top") self.run_cmd(mode, "begin-selection") self.run_cmd(mode, "cursor-right", count=3) grid, cursor = mode.render(None) self.assertEqual([cell[2] for cell in grid[0][:5]], ["43", "43", "43", None, None]) self.assertTrue("".join(cell[0] for cell in grid[0]).rstrip().endswith("[0/0]")) self.assertEqual(cursor, (3, 0)) def test_view_mode_from_static_lines(self): pane = tai.MplexPane(1, 20, 4, 10, tempfile.gettempdir()) server = MplexStubServer() mode = tai.MplexCopyMode(server, pane, lines=["l%d" % n for n in range(10)]) self.assertEqual(mode.name, "view-mode") self.assertEqual(mode.oy, mode.hist()) self.assertEqual(mode.text(mode.top()), "l0") self.run_cmd(mode, "history-bottom") self.assertEqual(mode.text(mode.abs_y()), "l9") def test_unknown_command_is_an_error(self): server, pane, mode = self.make("x") with self.assertRaises(tai.MplexError): self.run_cmd(mode, "no-such-thing") def test_key_dispatch_uses_mode_keys_table(self): server, pane, mode = self.make("a\nb\nc", rows=3) server.options.set("mode-keys", "vi") self.run_cmd(mode, "history-top") mode.key("j", b"j", None) self.assertEqual(mode.abs_y(), 1) server.options.set("mode-keys", "emacs") mode.key("C-n", b"\x0e", None) self.assertEqual(mode.abs_y(), 2) mode.key("q", b"q", None) self.assertNotIn(mode, pane.modes) class MplexCopyModeVersusTmuxTests(unittest.TestCase): """The same key sequences, run in the installed tmux 3.6 and recorded, must give the same cursor, scroll position and paste buffer here (see MPLEX_TMUX_CASES).""" @staticmethod def expand(commands): out = [] for part in commands.split(";"): if "*" in part: name, count = part.rsplit("*", 1) out.extend([name] * int(count)) else: out.append(part) return out def run_sequence(self, keys, commands, count=1): pane = tai.MplexPane(1, 30, 6, 100, tempfile.gettempdir()) pane.feed(("\r\n".join(MPLEX_TMUX_CONTENT) + "\r\n").encode()) server = MplexStubServer() server.options.set("mode-keys", keys) mode = tai.MplexCopyMode(server, pane) pane.push_mode(mode) steps = self.expand(commands) for index, step in enumerate(steps): words = step.split() pane.prefix_count = count if index == len(steps) - 1 and count > 1 else 1 try: mode.command(words[0], words[1:], None) except tai.MplexError: pass if mode not in pane.modes: break return server, mode def test_cursor_and_scroll_results_match_tmux(self): wrong = [] for keys, commands, count, expected in MPLEX_TMUX_CASES: _server, mode = self.run_sequence(keys, commands, count) got = "%d %d %d" % (mode.cx, mode.cy, mode.oy) if got != expected: wrong.append("%s %s (x%d): tmux %s, here %s" % (keys, commands[-90:], count, expected, got)) self.assertEqual(wrong, [], "%d of %d differ:\n%s" % (len(wrong), len(MPLEX_TMUX_CASES), "\n".join(wrong[:12]))) def test_selections_and_copies_match_tmux(self): wrong = [] for keys, commands, expected in MPLEX_TMUX_SELECTIONS: server, _mode = self.run_sequence(keys, "history-top;" + commands) got = server.copied[-1][0] if server.copied else "" if got != expected: wrong.append("%s %s: tmux %r, here %r" % (keys, commands[-80:], expected, got)) self.assertEqual(wrong, [], "%d of %d differ:\n%s" % (len(wrong), len(MPLEX_TMUX_SELECTIONS), "\n".join(wrong[:8]))) def test_fixture_set_is_not_empty(self): self.assertGreater(len(MPLEX_TMUX_CASES), 700) self.assertGreater(len(MPLEX_TMUX_SELECTIONS), 40) class MplexDefaultCopyBindingsTests(unittest.TestCase): def test_every_default_copy_mode_binding_runs_without_crashing(self): config = tai.MplexConfig() ran = 0 for table in ("copy-mode", "copy-mode-vi"): for key, binding in config.tables[table].items(): words = tai.MplexCommandParser.parse(binding.command)[0] if words[0] == "command-prompt": words = tai.MplexCommandParser.parse(words[-1].replace("%%", "3"))[0] if words[:2] != ["send-keys", "-X"]: continue words = [item for item in words[2:] if item != "--"] pane = tai.MplexPane(1, 30, 6, 100, tempfile.gettempdir()) pane.feed(("\r\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\r\n").encode()) server = MplexStubServer() mode = tai.MplexCopyMode(server, pane) pane.push_mode(mode) mode.command("page-up", [], None) mode.command("begin-selection", [], None) pane.prefix_count = 2 try: mode.command(words[0], words[1:], None) except tai.MplexError as exc: self.assertIn("Search failed", str(exc), "%s %s" % (table, key)) ran += 1 self.assertGreater(ran, 100) class MplexSpecTests(unittest.TestCase): """mplex.md is the specification; it must list exactly what the code has.""" @classmethod def setUpClass(cls): with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mplex.md"), "r", encoding="utf-8") as handle: cls.doc = handle.read() def section(self, heading): start = self.doc.index("\n" + heading + "\n") rest = self.doc[start + len(heading) + 2:] end = re.search(r"\n#{1,3} ", rest) return rest[:end.start()] if end else rest def first_column(self, text): return re.findall(r"^\| `([^`]+)` \|", text, re.MULTILINE) def test_options_table_matches_the_code(self): self.assertEqual(sorted(self.first_column(self.section("## Options"))), sorted(tai.MplexOptions.DEFS)) def test_commands_table_matches_the_code(self): self.assertEqual(sorted(self.first_column(self.section("## Commands"))), sorted(tai.MPLEX_COMMAND_SPECS)) def test_unsupported_commands_are_all_named(self): for name in tai.MPLEX_UNSUPPORTED: if name != "choose-tree-mouse": self.assertIn("`%s`" % name, self.doc) def test_every_default_binding_is_documented(self): for table, heading in (("prefix", "### prefix table (after `C-b`)"), ("copy-mode", "### copy-mode table (`mode-keys emacs`)"), ("copy-mode-vi", "### copy-mode-vi table (`mode-keys vi`)")): section = self.section(heading) documented = re.findall(r"^\| `(.+?)` \| `(.+?)` \| (yes)?", section, re.MULTILINE) documented = [(key.replace("\\|", "|"), command.replace("\\|", "|"), bool(rep)) for key, command, rep in documented] expected = [(key, command, repeat) for tbl, key, command, repeat, _note in tai.MPLEX_DEFAULT_KEYS if tbl == table] self.assertEqual(documented, expected, table) def test_every_copy_mode_command_is_documented(self): for name in dir(tai.MplexCopyMode): if name.startswith("c_"): self.assertIn("`%s`" % name[2:].replace("_", "-"), self.doc, name) def test_every_cap_is_documented(self): for name in ("MPLEX_MAX_SESSIONS", "MPLEX_MAX_WINDOWS", "MPLEX_MAX_PANES", "MPLEX_MAX_CLIENTS", "MPLEX_FRAME_MAX", "MPLEX_CLIENT_OUT_MAX", "MPLEX_PANE_IN_MAX", "MPLEX_HISTORY_CAP", "MPLEX_LOG_MAX", "MPLEX_BUFFERS_CAP", "MPLEX_MESSAGES_MAX", "MPLEX_PROMPT_HISTORY_CAP", "MPLEX_COMMAND_DEPTH_MAX", "MPLEX_LOOP_ERRORS_MAX", "MPLEX_SHELL_TIMEOUT", "MPLEX_CONFIG_MAX", "MPLEX_PASTE_MAX", "MPLEX_RENDER_INTERVAL", "MPLEX_REAP_KILL_AFTER"): self.assertIn("`%s`" % name, self.doc, name) self.assertTrue(hasattr(tai, name), name) def test_wire_kinds_are_documented(self): for kind in ("H", "I", "R", "A", "O", "M", "E", "D", "X", "Z"): self.assertRegex(self.doc, r"`%s`" % kind) def test_no_em_dashes(self): self.assertNotIn("—", self.doc) class MplexSessionTests(unittest.TestCase): """Window selection history; the expectations were taken from tmux 3.6 (compare3: select, kill, swap).""" def make(self, count): session = tai.MplexSession(0, "s", tai.MplexOptions(), "/", {}) for index in range(count): window = tai.MplexWindow(index, "w%d" % index, 80, 24, session.options) window.session = session session.windows[index] = window session.current = 0 return session def test_last_is_the_window_that_was_current_before(self): session = self.make(3) self.assertIsNone(session.last) session.select(1) self.assertEqual(session.last, 0) session.select(2) self.assertEqual((session.current, session.last), (2, 1)) session.select(1) self.assertEqual((session.current, session.last), (1, 2)) def test_history_keeps_older_windows_behind_the_last_one(self): session = self.make(3) session.select(1) session.select(2) session.history = [w for w in session.history if w is not session.windows[2]] self.assertEqual([w.name for w in session.history], ["w1", "w0"]) def test_free_index_fills_gaps_from_the_base(self): session = self.make(3) del session.windows[1] self.assertEqual(session.free_index(0), 1) self.assertEqual(session.free_index(5), 5) def test_server_picks_last_then_previous_then_wraps_when_the_current_window_dies(self): server = tai.MplexServer.__new__(tai.MplexServer) server.windows = {} server.panes = {} server.marked = None server.options = tai.MplexOptions() server.clients = [] server.sessions = {} for scenario, current, selects, kill, expect in ( ("last wins", 0, (1, 2), 2, 1), ("wraps to the highest when nothing is below", 0, (), 0, 2), ("previous below the killed index when there is no history", 1, (), 1, 0), ): session = self.make(3) session.current = current server.sessions = {0: session} server.windows = {w.id: w for w in session.windows.values()} for index in selects: session.select(index) tai.MplexServer.kill_window(server, session.windows[kill]) self.assertEqual(session.current, expect, scenario) class MplexPromptTests(unittest.TestCase): class Client: def __init__(self): self.prompt = None self.redispatched = [] def redispatch(self, name, raw): self.redispatched.append(name) def make(self, text="", **kwargs): done = [] client = self.Client() history = [] prompt = tai.MplexPrompt(":", text, done.append, history, **kwargs) client.prompt = prompt server = MplexStubServer() server.top_buffer_text = lambda: "PASTE" return prompt, client, server, done, history def type(self, prompt, client, server, *names): for name in names: raw = name.encode() if len(name) == 1 else b"" prompt.key(server, client, "Space" if name == " " else name, b" " if name == " " else raw) def test_editing_keys(self): prompt, client, server, done, history = self.make("hello world") self.type(prompt, client, server, "C-a", "C-k") self.assertEqual(prompt.text, "") self.type(prompt, client, server, "a", "b", "c", "Left", "X") self.assertEqual((prompt.text, prompt.pos), ("abXc", 3)) self.type(prompt, client, server, "BSpace", "C-e", "C-h") self.assertEqual(prompt.text, "ab") self.type(prompt, client, server, " ", "w", "o", "r", "d", "C-w") self.assertEqual(prompt.text, "ab ") self.type(prompt, client, server, "C-u") self.assertEqual(prompt.text, "") self.type(prompt, client, server, "C-y") self.assertEqual(prompt.text, "PASTE") def test_enter_runs_and_records_history(self): prompt, client, server, done, history = self.make("go") self.type(prompt, client, server, "Enter") self.assertEqual(done, ["go"]) self.assertIsNone(client.prompt) self.assertEqual(history, ["go"]) def test_history_navigation(self): prompt, client, server, done, history = self.make("") history.extend(["one", "two"]) self.type(prompt, client, server, "Up", "Up") self.assertEqual(prompt.text, "one") self.type(prompt, client, server, "Down", "Down") self.assertEqual(prompt.text, "") def test_escape_cancels_and_calls_hook(self): cancelled = [] prompt, client, server, done, history = self.make("x", on_cancel=lambda: cancelled.append(1)) self.type(prompt, client, server, "Escape") self.assertEqual((done, cancelled, client.prompt), ([], [1], None)) def test_single_key_prompt(self): prompt, client, server, done, history = self.make("", single=True) self.type(prompt, client, server, "z") self.assertEqual(done, ["z"]) self.assertEqual(history, []) def test_confirm_prompt_only_accepts_y(self): prompt, client, server, done, history = self.make("", confirm=True) self.type(prompt, client, server, "n") self.assertEqual((done, client.prompt), ([], None)) prompt, client, server, done, history = self.make("", confirm=True) self.type(prompt, client, server, "y") self.assertEqual(done, ["y"]) def test_numeric_prompt_hands_back_the_next_key(self): prompt, client, server, done, history = self.make("1", numeric=True) self.type(prompt, client, server, "2", "j") self.assertEqual(done, ["12"]) self.assertEqual(client.redispatched, ["j"]) def test_key_name_prompt(self): prompt, client, server, done, history = self.make("", key_name=True) prompt.key(server, client, "C-x", b"\x18") self.assertEqual(done, ["C-x"]) def test_incremental_hooks(self): seen = [] steps = [] prompt, client, server, done, history = self.make("", on_change=seen.append, on_step=lambda name, text: steps.append((name, text))) self.type(prompt, client, server, "a", "b", "C-s", "C-r") self.assertEqual(seen, ["a", "ab"]) self.assertEqual(steps, [("C-s", "ab"), ("C-r", "ab")]) def test_history_is_capped(self): prompt, client, server, done, history = self.make("x") history.extend(str(n) for n in range(tai.MPLEX_PROMPT_HISTORY_CAP + 20)) self.type(prompt, client, server, "Enter") self.assertEqual(len(history), tai.MPLEX_PROMPT_HISTORY_CAP) class MplexCommandTemplateTests(unittest.TestCase): def setUp(self): self.commands = tai.MplexCommands(None) def test_percent_substitution(self): sub = self.commands.substitute self.assertEqual(sub("rename-window %%", ["x"]), "rename-window x") self.assertEqual(sub("a %1 b %2", ["one", "two"]), "a one b two") self.assertEqual(sub("a %3", ["one"]), "a ") def test_quotes_inside_quoted_placeholder_are_escaped(self): sub = self.commands.substitute self.assertEqual(sub('rename-window "%%"', ['a"b$c\\d']), 'rename-window "a\\"b\\$c\\\\d"') parsed = tai.MplexCommandParser.parse(sub('rename-window "%%"', ['a"b; kill-server'])) self.assertEqual(parsed, [["rename-window", 'a"b; kill-server']]) def test_command_resolution(self): r = tai.MplexCommands.resolve self.assertEqual(r("neww"), "new-window") self.assertEqual(r("splitw"), "split-window") self.assertEqual(r("ls"), "list-sessions") self.assertEqual(r("kill-ser"), "kill-server") for bad, needle in (("kill", "ambiguous"), ("nonsense", "unknown command"), ("display-popup", "not supported")): with self.assertRaises(tai.MplexError) as caught: r(bad) self.assertIn(needle, str(caught.exception)) def test_every_spec_has_a_handler(self): for name in tai.MPLEX_COMMAND_SPECS: self.assertTrue(hasattr(tai.MplexCommands, "cmd_" + name.replace("-", "_")), name) def test_unsupported_commands_have_no_spec(self): for name in tai.MPLEX_UNSUPPORTED: self.assertNotIn(name, tai.MPLEX_COMMAND_SPECS) class MplexFramingTests(unittest.TestCase): def test_frames_reassemble_and_reject_oversize(self): client = tai.MplexClientConn(None, None, 1) frame = b"I" + (3).to_bytes(4, "big") + b"abc" self.assertEqual(client.frames(frame[:4]), []) self.assertEqual(client.frames(frame[4:] + frame), [(b"I", b"abc"), (b"I", b"abc")]) self.assertIsNone(client.frames(b"I" + (tai.MPLEX_FRAME_MAX + 1).to_bytes(4, "big"))) def test_send_is_capped(self): client = tai.MplexClientConn(None, None, 1) client.out = bytearray(tai.MPLEX_CLIENT_OUT_MAX + 1) client.prev = [1] self.assertFalse(client.send(b"O", b"x")) self.assertIsNone(client.prev) class MplexServerLoopTests(unittest.TestCase): def make(self, behaviour): server = tai.MplexServer.__new__(tai.MplexServer) server.running = True server.calls = 0 def iterate(): server.calls += 1 behaviour(server) server.iterate = iterate return server def test_a_failing_iteration_is_survived_and_logged(self): def behaviour(server): if server.calls <= 2: raise RuntimeError("boom %d" % server.calls) server.running = False server = self.make(behaviour) err = io.StringIO() with contextlib.redirect_stderr(err): tai.MplexServer.loop(server) self.assertEqual(server.calls, 3) self.assertIn("boom 1", err.getvalue()) self.assertIn("boom 2", err.getvalue()) def test_endless_failures_end_the_loop(self): def behaviour(server): raise RuntimeError("always") server = self.make(behaviour) with contextlib.redirect_stderr(io.StringIO()): tai.MplexServer.loop(server) self.assertEqual(server.calls, tai.MPLEX_LOOP_ERRORS_MAX) self.assertFalse(server.running) class MplexPaneEnvTests(unittest.TestCase): def test_controller_targets_its_pane_from_the_environment(self): with mock.patch.dict(os.environ, {tai.PLEXER_SOCKET_ENV: "/tmp/x.sock", tai.PLEXER_PANE_ENV: "7"}, clear=False): controller = tai.PlexerControllerFactory.detect() self.assertIsInstance(controller, tai.NativePlexerController) self.assertEqual(controller.pane, "7") class ShellSessionTests(unittest.TestCase): BTIME = 1700000000.0 BOOT_ID = "12345678-9abc-def0-1234-56789abcdef0\n" def write_proc(self, root, pid, ppid, comm, argv0, ticks): folder = os.path.join(root, str(pid)) os.makedirs(folder, exist_ok=True) stat = "%d (%s) R %d %s %d 0 0" % (pid, comm, ppid, " ".join(["0"] * 17), ticks) with open(os.path.join(folder, "stat"), "w", encoding="utf-8") as handle: handle.write(stat) with open(os.path.join(folder, "cmdline"), "w", encoding="utf-8") as handle: handle.write(argv0 + "\0") def proc_root(self, tmp, members=((50, 20, "python3", "python3", 100000), (20, 10, "bash", "/bin/bash", 50000), (10, 1, "bash", "/bin/bash", 10000), (1, 0, "systemd", "/sbin/init", 100))): root = os.path.join(tmp, "proc") for pid, ppid, comm, argv0, ticks in members: self.write_proc(root, pid, ppid, comm, argv0, ticks) with open(os.path.join(root, "stat"), "w", encoding="utf-8") as handle: handle.write("cpu 0\nbtime %d\n" % int(self.BTIME)) boot_dir = os.path.join(root, "sys/kernel/random") os.makedirs(boot_dir, exist_ok=True) with open(os.path.join(boot_dir, "boot_id"), "w", encoding="utf-8") as handle: handle.write(self.BOOT_ID) return root def test_derived_id_is_stable_dated_and_pid_scoped(self): with tempfile.TemporaryDirectory() as tmp: root = self.proc_root(tmp) first = tai.ShellSession.current(env={}, pid=50, proc_root=root, clk_tck=100) second = tai.ShellSession.current(env={}, pid=50, proc_root=root, clk_tck=100) self.assertEqual(first.sid, second.sid) self.assertEqual(first.sid, tai.ShellSession.shell_id(self.BTIME + 500, 20, "12345678")) self.assertIn("-p20-", first.sid) self.assertEqual((first.shell, first.pid), ("bash", 20)) self.assertEqual(first.parent, tai.ShellSession.shell_id(self.BTIME + 100, 10, "12345678")) def test_env_reused_when_pid_matches_shell(self): with tempfile.TemporaryDirectory() as tmp: root = self.proc_root(tmp) derived = tai.ShellSession.current(env={}, pid=50, proc_root=root, clk_tck=100).sid again = tai.ShellSession.current(env={tai.ShellSession.ENV: derived}, pid=50, proc_root=root, clk_tck=100) self.assertEqual(again.sid, derived) self.assertEqual(again.shell, "bash") def test_env_superseded_by_nested_shell_links_parent(self): with tempfile.TemporaryDirectory() as tmp: root = self.proc_root(tmp, members=((50, 30, "python3", "python3", 100000), (30, 20, "bash", "/bin/bash", 80000), (20, 1, "bash", "/bin/bash", 50000), (1, 0, "systemd", "/sbin/init", 100))) outer = tai.ShellSession.shell_id(self.BTIME + 500, 20, "12345678") ctx = tai.ShellSession.current(env={tai.ShellSession.ENV: outer}, pid=50, proc_root=root, clk_tck=100) self.assertIn("-p30-", ctx.sid) self.assertEqual(ctx.parent, outer) def test_pinned_word_is_used_verbatim(self): with tempfile.TemporaryDirectory() as tmp: root = self.proc_root(tmp) ctx = tai.ShellSession.current(env={tai.ShellSession.ENV: "main"}, pid=50, proc_root=root, clk_tck=100) self.assertEqual(ctx.sid, "main") def test_ephemeral_without_proc(self): first = tai.ShellSession.current(env={}, pid=4242, proc_root="/nonexistent-proc", now=1700000000.0) second = tai.ShellSession.current(env={}, pid=4242, proc_root="/nonexistent-proc", now=1700000000.0) self.assertIn("-e4242-", first.sid) self.assertNotEqual(first.sid, second.sid) def test_login_shell_argv0_detected(self): with tempfile.TemporaryDirectory() as tmp: root = self.proc_root(tmp, members=((50, 20, "python3", "python3", 100000), (20, 1, "bash", "-bash", 50000), (1, 0, "systemd", "/sbin/init", 100))) ctx = tai.ShellSession.current(env={}, pid=50, proc_root=root, clk_tck=100) self.assertEqual(ctx.shell, "bash") self.assertIn("-p20-", ctx.sid) def test_sanitize_coerce_and_parse_stat(self): self.assertEqual(tai.ShellSession.sanitize("../../x;rm A"), "....xrmA") self.assertEqual(tai.ShellSession.sanitize("x" * 100), "x" * tai.ShellSession.SID_MAX) self.assertEqual(tai.ShellSession.coerce(None), "") ctx = tai.ShellSession("abc") self.assertEqual(tai.ShellSession.coerce(ctx), "abc") self.assertEqual(tai.ShellSession.coerce("a/b"), "ab") self.assertIsNone(tai.ShellSession.parse_stat("garbage")) self.assertIsNone(tai.ShellSession.parse_stat("")) self.assertEqual(tai.ShellSession.parse_stat("9 (a(b) R 3 " + " ".join(["0"] * 17) + " 42 0")[1:], (3, 42)) def test_export_sets_global_var(self): with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop(tai.ShellSession.ENV, None) tai.ShellSession("s1").export() self.assertEqual(os.environ[tai.ShellSession.ENV], "s1") class CtxStoreTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() os.environ["TAI_HOME"] = self.tmp.name class FakeArgs: profile = "t" yes = False self.config = tai.Config(FakeArgs()) self.store = tai.Store(self.config, tai.Seal(self.config.home, "")) def tearDown(self): self.store.close() self.tmp.cleanup() os.environ.pop("TAI_HOME", None) def test_scoped_paths_leave_legacy_untouched(self): self.assertTrue(self.store.session_path("t", ctx="abc").endswith("t.session.abc.json")) self.assertTrue(self.store.session_path("t").endswith("t.session.json")) self.assertTrue(self.store.bot_session_path("t", "coder", ctx="abc").endswith("t.bot.coder.session.abc.json")) self.assertTrue(self.store.bot_session_path("t", "coder").endswith("t.bot.coder.session.json")) self.assertTrue(self.store.turn_state_path("t", "main", ctx="abc").endswith("t.turn.abc.json")) self.assertTrue(self.store.turn_state_path("t").endswith("t.turn.json")) def test_scoped_sessions_are_isolated_from_each_other_and_legacy(self): self.store.save_session("t", [{"role": "user", "content": "legacy"}]) self.store.save_session("t", [{"role": "user", "content": "one"}], ctx="one") self.store.save_session("t", [{"role": "user", "content": "two"}], ctx="two") self.assertEqual([item["content"] for item in self.store.load_session("t")], ["legacy"]) self.assertEqual([item["content"] for item in self.store.load_session("t", ctx="one")], ["one"]) self.assertEqual([item["content"] for item in self.store.load_session("t", ctx="two")], ["two"]) def test_ctx_in_path_is_filename_safe(self): path = self.store.session_path("t", ctx="../../x;rm") self.assertEqual(os.path.dirname(path), self.config.profiles_dir) self.assertNotIn("/", os.path.basename(path)) def test_prune_ctx_files_keeps_newest_and_legacy(self): self.store.save_session("t", [{"role": "user", "content": "legacy"}]) for num in range(5): self.store.save_session("t", [{"role": "user", "content": "m%d" % num}], ctx="c%d" % num) self.store.prune_ctx_files("t.session.", "t.session.json", cap=3) left = sorted(os.listdir(self.config.profiles_dir)) self.assertIn("t.session.json", left) self.assertEqual(len([entry for entry in left if entry.startswith("t.session.") and entry != "t.session.json"]), 3) def test_corrupt_scoped_session_quarantined(self): path = self.store.session_path("t", ctx="one") self.store.save_session("t", [{"role": "user", "content": "good"}], ctx="one") with open(path, "w", encoding="utf-8") as handle: handle.write("{not json") self.assertEqual(self.store.load_session("t", ctx="one"), []) leftovers = [entry for entry in os.listdir(self.config.profiles_dir) if ".corrupt-" in entry] self.assertEqual(len(leftovers), 1) def test_agent_ctx_isolation(self): first = tai.Agent(self.config, self.store, persist=True, quiet=True, ctx="one") first.messages.append({"role": "user", "content": "hello one"}) first.checkpoint() same = tai.Agent(self.config, self.store, persist=True, quiet=True, ctx="one") self.assertTrue(any("hello one" in (item.get("content") or "") for item in same.messages)) other = tai.Agent(self.config, self.store, persist=True, quiet=True, ctx="two") self.assertEqual([item["role"] for item in other.messages], ["system"]) legacy = tai.Agent(self.config, self.store, persist=True, quiet=True) self.assertEqual([item["role"] for item in legacy.messages], ["system"]) self.assertEqual(legacy.ctx_id, "") def test_agent_ctx_turn_state_scoped(self): agent = tai.Agent(self.config, self.store, persist=True, quiet=True, ctx="one") agent._turn_open = True agent._turn_goal = "goal one" agent.checkpoint() self.assertTrue(self.store.load_turn_state("t", "main", ctx="one")["open"]) self.assertEqual(self.store.load_turn_state("t", "main", ctx="two"), {}) self.assertEqual(self.store.load_turn_state("t", "main"), {}) def test_register_builds_parent_tree_and_report(self): ctx = tai.ShellSession("inner", shell="bash", pid=20, started="s", parent="outer", chain=[{"pid": 20, "ppid": 10, "comm": "bash", "argv0": "bash", "name": "bash", "start": 1.0, "start_iso": "s", "sid": "inner"}, {"pid": 10, "ppid": 1, "comm": "bash", "argv0": "bash", "name": "bash", "start": 0.5, "start_iso": "s0", "sid": "outer"}]) ctx.register(self.store) rows = dict(self.store.db.execute("SELECT sid, parent FROM shell_sessions").fetchall()) self.assertEqual(rows["inner"], "outer") self.assertIn("outer", rows) ctx.register(self.store) self.assertEqual(self.store.db.execute("SELECT COUNT(*) FROM shell_sessions").fetchone()[0], 2) text = tai.ShellSession.report(self.store, "inner") self.assertIn("ctx: inner", text) self.assertIn("parent: outer", text) self.assertIn("recent:", text) def test_register_prunes_rows_and_report_handles_legacy(self): with mock.patch.object(tai.ShellSession, "ROWS_CAP", 3): for num in range(5): self.store.db.execute("INSERT INTO shell_sessions (sid, started) VALUES (?, ?)", ("old%d" % num, "2020-01-0%d" % (num + 1))) self.store.db.commit() tai.ShellSession("fresh", chain=[]).register(self.store) self.assertLessEqual(self.store.db.execute("SELECT COUNT(*) FROM shell_sessions").fetchone()[0], 3) self.assertIn("(legacy", tai.ShellSession.report(self.store, "")) def seed_tree(self, rows): for sid, parent in rows: self.store.db.execute("INSERT INTO shell_sessions (sid, parent, shell, pid) VALUES (?, ?, ?, ?)", (sid, parent, "bash", 1)) self.store.db.commit() def test_tree_shows_lineage_and_children(self): self.seed_tree([("root", None), ("mid", "root"), ("leaf", "mid"), ("sib", "root"), ("kid", "leaf")]) lines = tai.ShellSession.tree_lines(self.store, "mid") self.assertEqual([line.strip()[:4] for line in lines], ["root", "* mi", "leaf", "kid "]) self.assertTrue(lines[0].startswith("root")) self.assertTrue(lines[1].startswith(" * mid")) self.assertTrue(lines[2].startswith(" leaf")) text = tai.ShellSession.report(self.store, "mid") self.assertIn("tree:", text) self.assertIn("* mid", text) def test_tree_cycle_and_orphan_safe(self): self.seed_tree([("a", "b"), ("b", "a")]) lines = tai.ShellSession.tree_lines(self.store, "a") self.assertTrue(any(line.strip().startswith("* a") for line in lines)) self.store.db.execute("INSERT INTO shell_sessions (sid, parent, shell) VALUES (?, ?, ?)", ("orph", "gone", "sh")) self.store.db.commit() lines = tai.ShellSession.tree_lines(self.store, "orph") self.assertTrue(any("(pruned)" in line for line in lines)) self.assertEqual(tai.ShellSession.tree_lines(self.store, "missing"), []) def test_tree_caps_fanout_and_depth(self): with mock.patch.object(tai.ShellSession, "TREE_KIDS", 2), mock.patch.object(tai.ShellSession, "TREE_LINES", 5): rows = [("top", None)] + [("c%02d" % num, "top") for num in range(5)] self.seed_tree(rows) lines = tai.ShellSession.tree_lines(self.store, "top") self.assertLessEqual(len(lines), 5) self.assertTrue(any("c00" in line for line in lines)) self.assertFalse(any("c04" in line for line in lines)) if __name__ == "__main__": unittest.main()