Update.
This commit is contained in:
+8
-5
@@ -6,11 +6,11 @@
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Subjects | 4 |
|
||||
| Total source documents | 292 |
|
||||
| — Tier 1 (Source Dossier, independently analyzed) | 94 |
|
||||
| — Tier 2 (Source Citation Card, listed only) | 198 |
|
||||
| Total conclusion documents | 5 |
|
||||
| Subjects | 5 |
|
||||
| Total source documents | 346 |
|
||||
| — Tier 1 (Source Dossier, independently analyzed) | 103 |
|
||||
| — Tier 2 (Source Citation Card, listed only) | 243 |
|
||||
| Total conclusion documents | 6 |
|
||||
| Archive opened | 2026-10-09 |
|
||||
| Last updated | 2026-10-10 |
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
| [`agent-memory-recency-tracking`](agent-memory-recency-tracking/) | 63 (16 / 47) | 1 | 2026-10-09 | 2026-10-09 | Designing an LRU-style last-used/use-count tracker for vault records and chunks |
|
||||
| [`async-pty-process-testing`](async-pty-process-testing/) | 59 (20 / 39) | 1 | 2026-10-09 | 2026-10-09 | Designing tai_test.py (pty vs. pipes, non-blocking stdin, process-group cleanup, test-framework shape) — backfilled after it was initially skipped, see activity log |
|
||||
| [`shell-multiplexer-auto-attach`](shell-multiplexer-auto-attach/) | 138 (48 / 90) | 2 | 2026-10-09 | 2026-10-10 | Designing a tai-managed terminal multiplexer + bashrc auto-attach: tmux-as-substrate recommendation, interactive-vs-automation shell detection; extended 2026-10-10 to cover zsh/fish hook syntax |
|
||||
| [`tui-framework-design`](tui-framework-design/) | 54 (9 / 45) | 1 | 2026-10-10 | 2026-10-10 | Designing tai's in-house TUI framework (retained DOM, cuts/chops compositor, SGR mouse, bubbling events, VT sub-terminals); extended 2026-10-10 with synchronized-output verification (DEC 2026); implementation approved and in progress |
|
||||
|
||||
## Recent activity log
|
||||
|
||||
@@ -34,6 +35,8 @@
|
||||
| 2026-10-09 | `async-pty-process-testing` | New subject, opened as a correction: the eight `WebSearch` calls behind `tai_test.py`'s design (pty vs. pipes, non-blocking stdin, process-group cleanup, test-framework patterns) were initially skipped from the archive under the reasoning "informal engineering reference, not a design decision" — a silent, unilateral exception to the Research Rule, and wrong (see `README.md` §4's added "no exception, ever" paragraph). Backfilled: 59 sources (20 Tier 1 / 39 Tier 2) + 1 conclusion (`conclusions/tai-test-framework-design.md`). Counts verified directly against the written files via `research/audit.py`, not taken from the authoring agent's self-report alone. A `PostToolUse`/`WebSearch` hook (`.claude/settings.json`) was also added the same day specifically to stop this from recurring. |
|
||||
| 2026-10-09 | `shell-multiplexer-auto-attach` | 71 sources (38 Tier 1 / 33 Tier 2) + 1 conclusion recorded (the conclusion: build on tmux as a substrate rather than a from-scratch multiplexer; reliable human-vs-automation shell detection has no single solution, only a combination of weaker signals plus a safety-file escape hatch). Counts verified directly against the written files via `research/audit.py`. Hook fired correctly for all 8 underlying WebSearch calls during this task. |
|
||||
| 2026-10-10 | `shell-multiplexer-auto-attach` | Extended with 67 more sources (10 Tier 1 / 57 Tier 2) + 1 more conclusion (`conclusions/cross-shell-hook-syntax-bash-zsh-fish.md`), covering 7 WebSearch calls on zsh/fish command-not-found and plexer-hook syntax (fish_command_not_found, zsh command_not_found_handler, fish's isatty builtin vs. a missing `test -t`, `status is-interactive`/`is-login`, config.fish always being sourced, and the `fish -i` flag). Subject total now 138 sources (48 Tier 1 / 90 Tier 2), 2 conclusions. The research backed a same-session implementation (tai.py's `shell_rc_path`/`ZSH_NOTFOUND_BLOCK`/`FISH_NOTFOUND_BLOCK`/`FISH_PLEXER_BLOCK`/`op_zsh_hook`/`op_fish_hook`/`op_zsh_plexer_hook`/`op_fish_plexer_hook`, new tests in `test_tai.py` and `tests/bashrc.py`, and `zsh`/`fish` added to `tests/docker/Dockerfile`) rather than remaining a proposal. Counts verified directly against the written files via `research/audit.py` (PASS). |
|
||||
| 2026-10-10 | `tui-framework-design` | New subject: 44 sources (7 Tier 1 / 37 Tier 2) + 1 conclusion (`conclusions/retained-dom-compositor-tui.md`), covering 4 WebSearch calls plus 7 direct page fetches on TUI architecture (Textual events/layout/animation guides, pyte repo, SGR mouse protocol, rabbitui Textual synthesis, tmux popup-removal commit). Decision: retained-DOM framework in tai.py with cuts/chops compositor, named layers, bubbling events, SGR mouse, tick animations, reimplemented VT sub-terminals. Nothing built yet; implementation plan presented, awaiting go. Counts hand-verified (shell unavailable for `research/audit.py` in this session). |
|
||||
| 2026-10-10 | `tui-framework-design` | Extended with 10 more sources (2 Tier 1 / 8 Tier 2) verifying synchronized-output framing (DEC mode 2026, `CSI ? 2026 h/l`, harmless no-op where unsupported). Subject total now 54 sources (9 Tier 1 / 45 Tier 2), 1 conclusion. Implementation approved by retoor the same session; framework being built into tai.py. |
|
||||
|
||||
## How to use this index
|
||||
|
||||
|
||||
-8643
File diff suppressed because it is too large
Load Diff
+30
-2
@@ -163,6 +163,31 @@ class ChatClient:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def summarize_errors(errors, shown=2, width=320):
|
||||
"""One-line summary of a failed retry round.
|
||||
|
||||
Missing-CLI entries are expected on machines without those CLIs,
|
||||
so actionable failures lead and the missing-CLI tail never takes
|
||||
the only display slot (CLI candidates run last, and the muse
|
||||
candidate is always appended even when its binary is absent).
|
||||
"""
|
||||
if not errors:
|
||||
return "no details"
|
||||
actionable = [entry for entry in errors if "CLI not found" not in entry]
|
||||
pool = actionable or errors
|
||||
unique = []
|
||||
for entry in pool:
|
||||
if entry not in unique:
|
||||
unique.append(entry)
|
||||
text = "; ".join(unique[:shown])
|
||||
if len(unique) > shown:
|
||||
text += "; +%d more" % (len(unique) - shown)
|
||||
skipped = len(errors) - len(pool)
|
||||
if skipped:
|
||||
text += " (%d missing CLI skipped)" % skipped
|
||||
return text[:width]
|
||||
|
||||
def complete(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None):
|
||||
payload = {"model": self.config.model, "messages": messages}
|
||||
if tools:
|
||||
@@ -183,8 +208,11 @@ class ChatClient:
|
||||
pause = Consts.RETRY_BACKOFF[min(round_no, len(Consts.RETRY_BACKOFF) - 1)]
|
||||
round_no += 1
|
||||
if give_up is not None and time.time() + pause > give_up:
|
||||
raise BackendError("all backends failed after %d rounds (%s)" % (round_no, "; ".join(errors[-6:])))
|
||||
print(Render.paint("all %d backends failed (round %d), retrying in %ds: %s" % (len(cands), round_no, pause, (errors[-1] if errors else "")[:160]), Ansi.DIM), file=sys.stderr)
|
||||
raise BackendError("all backends failed after %d rounds (%s)" % (round_no, self.summarize_errors(errors, shown=6, width=900)))
|
||||
providers = len(set((cand["provider"], cand["chat_url"]) for cand in cands))
|
||||
if sys.stderr.isatty():
|
||||
sys.stderr.write("\n")
|
||||
print(Render.paint("all %d candidate%s across %d backend%s failed (round %d), retrying in %ds: %s" % (len(cands), "" if len(cands) == 1 else "s", providers, "" if providers == 1 else "s", round_no, pause, self.summarize_errors(errors)), Ansi.DIM), file=sys.stderr)
|
||||
if round_no == 1 and not Opencode.opencode_binary():
|
||||
engine = Sandbox.container_engine()
|
||||
if engine and Sandbox.box_state(engine) == "missing":
|
||||
|
||||
+601
@@ -3446,6 +3446,607 @@ class OpencodeParseTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(reply["tool_calls"][0]["arguments"]), {"command": "ls -lt"})
|
||||
|
||||
|
||||
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 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_terminal_keeps_tab(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, term)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@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 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 EmbeddedStringsTests(unittest.TestCase):
|
||||
def test_box_scripts_compile(self):
|
||||
for name in ("BOX_STT", "BOX_TTS"):
|
||||
|
||||
Reference in New Issue
Block a user