Chat composer: readline-style input history with Up/Down, shared with the REPL history file

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:50:17 +02:00
co-authored by Claude Sonnet 5.5
parent 80bb245be7
commit 305b8f747e
3 changed files with 195 additions and 6 deletions
+94 -3
View File
@@ -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("/"):
+91
View File
@@ -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
+10 -3
View File
@@ -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