diff --git a/tai.py b/tai.py index 4869462..4c2ef4c 100755 --- a/tai.py +++ b/tai.py @@ -97,7 +97,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.29.0" +VERSION = "1.30.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -9735,6 +9735,8 @@ def boot(args): TUI_FPS = 30 +TUI_HISTORY_CAP = 1000 + TUI_INPUT_PAINT_GAP = 0.008 TUI_MIN_ROWS = 8 @@ -12085,6 +12087,72 @@ def tui_complete_command(prefix): return found +class TuiHistory: + """Input history with readline semantics: up recalls older entries, down + walks back toward the unfinished draft you started from. Optionally + backed by a plain one-entry-per-line file, the same file the REPL's + readline uses, so both front ends share it. Capped at TUI_HISTORY_CAP + entries; multi-line entries persist with newlines folded to spaces + because the file format is one line per entry.""" + + def __init__(self, path=None, cap=TUI_HISTORY_CAP): + self.path = path + self.cap = max(1, int(cap)) + self.entries = [] + self.pos = None + self.draft = "" + self.write_errors = 0 + if path: + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + self.entries = [line.rstrip("\n") for line in handle if line.strip()][-self.cap:] + except OSError: + self.entries = [] + + def add(self, text): + text = str(text or "").strip() + self.reset() + if not text or (self.entries and self.entries[-1] == text): + return + self.entries.append(text) + del self.entries[:-self.cap] + if self.path: + try: + with open(self.path, "a", encoding="utf-8") as handle: + handle.write(text.replace("\n", " ") + "\n") + except OSError: + self.write_errors += 1 + + def reset(self): + self.pos = None + self.draft = "" + + def older(self, current): + """Previous entry, or None at the oldest. The first step remembers + current as the draft.""" + if not self.entries: + return None + if self.pos is None: + self.draft = str(current or "") + self.pos = len(self.entries) + if self.pos == 0: + return None + self.pos -= 1 + return self.entries[self.pos] + + def newer(self): + """Next entry; past the newest it returns the draft and leaves + browsing. None when not browsing.""" + if self.pos is None: + return None + self.pos += 1 + if self.pos >= len(self.entries): + draft = self.draft + self.reset() + return draft + return self.entries[self.pos] + + class TuiTextBox(TuiPanel): """Multiline text editor. Arrows/home/end/pgup/pgdn move, Enter splits lines, Backspace/Delete join them, clicks position the cursor. Escape @@ -12093,7 +12161,7 @@ class TuiTextBox(TuiPanel): prefix-to-items callable for /words and mentions= the same for @words, both driving a TuiAutocomplete popup.""" - def __init__(self, text="", complete=None, mentions=None, send_on_enter=False, on_send=None, max_chars=0, placeholder="", **kwargs): + def __init__(self, text="", complete=None, mentions=None, send_on_enter=False, on_send=None, max_chars=0, placeholder="", history=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) @@ -12108,6 +12176,7 @@ class TuiTextBox(TuiPanel): self.on_send = on_send self.max_chars = max(0, int(max_chars or 0)) self.placeholder = placeholder or "" + self.history = history @property def value(self): @@ -12331,9 +12400,15 @@ class TuiTextBox(TuiPanel): self.cx += 1 elif key == "up": event.stop() + if self.history is not None and self.cy == 0: + self._recall(self.history.older(self.value)) + return self.cy -= 1 elif key == "down": event.stop() + if self.history is not None and self.cy == len(self.lines) - 1 and self.history.pos is not None: + self._recall(self.history.newer()) + return self.cy += 1 elif key == "home": event.stop() @@ -12391,6 +12466,19 @@ class TuiTextBox(TuiPanel): self._touch() self._complete_refresh() + def _recall(self, text): + """Replace the buffer with a history entry, cursor at the end like + readline. None (nothing older/newer) leaves the buffer alone.""" + if text is None: + return + self.lines = str(text).split("\n") or [""] + self.cy = len(self.lines) - 1 + self.cx = len(self.lines[-1]) + self.top = 0 + self._complete_hide() + self._clamp() + self._touch() + def on_click(self, event): if not self.enabled or not event.pressed: return @@ -13977,7 +14065,8 @@ def tui_build_chat(app, agent): complete=tui_complete_command, mentions=lambda prefix: tui_complete_mention(prefix, bots=tuple(refs["bots"])), send_on_enter=True, on_send=lambda value: do_send(value), - max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...") + max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...", + history=TuiHistory(getattr(getattr(agent, "config", None), "history_path", None))) send_btn = TuiButton("Send ➤", brackets=False, w=10, h=1, border=True, rounded=True, border_style=TuiStyle(bg="role:bg", fg="role:accent"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent2"), @@ -14227,6 +14316,7 @@ def tui_build_chat(app, agent): box.cx = len(box.lines[-1]) box.cy = len(box.lines) - 1 box.top = 0 + box.history.reset() refresh_bots() render_history() paint_status() @@ -14261,6 +14351,7 @@ def tui_build_chat(app, agent): text = (value or "").strip() if not text or refs["pending"] is not None or refs["busy"]: return + box.history.add(text) clear_box() refs["drafts"][agent.bot] = "" if text.startswith("/"): diff --git a/test_tai.py b/test_tai.py index dd9828d..4c661b8 100644 --- a/test_tai.py +++ b/test_tai.py @@ -6157,6 +6157,97 @@ class TuiChatTests(unittest.TestCase): 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 test_history_cap_and_persistence_shared_file(self): + path = os.path.join(tempfile.mkdtemp(), "history") + with open(path, "w", encoding="utf-8") as handle: + handle.write("from repl\n\nsecond\n") + hist = tai.TuiHistory(path) + self.assertEqual(hist.entries, ["from repl", "second"]) + hist.add("multi\nline") + again = tai.TuiHistory(path) + self.assertEqual(again.entries, ["from repl", "second", "multi line"]) + small = tai.TuiHistory(cap=3) + for n in range(10): + small.add("m%d" % n) + self.assertEqual(small.entries, ["m7", "m8", "m9"]) + blocked = tai.TuiHistory(os.path.join(path, "nope", "history")) + blocked.add("x") + self.assertEqual(blocked.entries, ["x"]) + self.assertEqual(blocked.write_errors, 1) + + 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_hidden_sidebar_frees_its_columns(self): app, _agent, _root, refs = self.make_chat(cols=100) right = refs["transcript"].parent diff --git a/tui.md b/tui.md index 315908f..ce50665 100644 --- a/tui.md +++ b/tui.md @@ -182,6 +182,11 @@ callbacks; exceptions in callbacks never break the loop. Press event); `on_close(event)` fires on close. - `TuiQuestion(prompt, options, on_done, multi)` - prompt plus options; single-select answers on first pick, multi-select via OK. +- `TuiHistory(path, cap)` - readline-style input history: `older(current)` + / `newer()`, duplicate-of-last skipped, capped at `TUI_HISTORY_CAP` + (1000), optionally backed by a one-entry-per-line file (the chat scene + shares the REPL's `history` file; multi-line entries persist with + newlines folded to spaces). Pass it as `TuiTextBox(history=...)`. - `TuiTextBox(text, complete, mentions)` - multiline editor; arrows/ home/end/pgup/pgdn move (no line wrap), Enter splits, Backspace/Delete join, click positions the cursor, Escape bubbles @@ -266,7 +271,10 @@ lands as an incoming bubble. The transcript keeps a block journal and replays it on resize, so bubbles reflow instead of freezing at the old width; below usable width they degrade to full-width rows. -Keys: Enter sends (Shift+Enter / Alt+Enter newline), F2 toggles the +Keys: Enter sends (Shift+Enter / Alt+Enter newline), Up / Down recall +input history like readline (Up on the first line goes older, Down on +the last line goes newer and ends on your unfinished draft; an open +completion popup keeps the arrows), F2 toggles the sidebar, F4 cycles the theme, `i` or Esc jumps to the input, `q` quits from outside the input. Below 80 columns the sidebar auto-hides (`TUI_CHAT_NARROW_COLS`); a manual F2 hide/show wins over @@ -328,8 +336,7 @@ the widget shows a plain error line instead of crashing. blocks; there are no bubble tails, no unread pills or per-chat times (no honest data source), no attach/voice or reply-preview (no function behind them), tool-call chips are not - collapsible and show no results, no per-token streaming, no - send-history recall. + collapsible and show no results, no per-token streaming. - No per-widget async tasks; blocking work belongs in threads that post back via timers. - The VT parser drops provably-malformed tails past 64KB and caps