diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 36e4166..265b09b 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -205,6 +205,10 @@ async def attach_session(self, session_id: str) -> None: self._ctx.session_id = session_id + # New session → fresh per-session message history (Up/Down recall is + # in-memory per session; not synced to the server). + if self._input_box: + self._input_box.reset_history() history: list[dict] = [] session: dict = {} try: @@ -271,6 +275,10 @@ resolved = FileRefResolver().resolve(text) self._chat_panel.add_user_message(resolved.prompt) + # Record the raw typed text in the per-session message history (recall + # via Up/Down on an empty input). Plain messages only — slash commands + # and !shell are handled above and not remembered. + self._input_box.append_history(text) if resolved.attachments: names = ", ".join( a.display_path + (" (truncated)" if a.truncated else "") diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index 7cfcee6..3a3eebf 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -21,6 +21,9 @@ from clients.terminal.tui.events import UserSubmitted from clients.terminal.tui.widgets.command_hints import CommandHints +# How many submitted messages to keep for Up/Down recall (per session). +_HISTORY_CAP = 10 + class _PromptInput(TextArea): """Multi-line text area that submits on Enter. @@ -39,11 +42,13 @@ ``/cmd args`` line is still routed to ``_run_command`` by the app). """ - def __init__(self, *args, hints: CommandHints | None = None, **kwargs) -> None: + def __init__(self, *args, hints: CommandHints | None = None, box=None, **kwargs) -> None: super().__init__(*args, **kwargs) # Sibling hints widget. Named with a suffix to avoid shadowing any # Textual Widget internals (see the _context/_render pitfall). self._hints_ref: CommandHints | None = hints + # Owning InputBox — owns the per-session message-history state. + self._box_ref = box async def _on_key(self, event: events.Key) -> None: hints = self._hints_ref @@ -57,14 +62,64 @@ event.stop() event.prevent_default() return - if event.key in ("up", "down") and hints is not None and hints.visible(): - delta = -1 if event.key == "up" else 1 - if hints.move_highlight(delta): + if event.key in ("up", "down"): + if hints is not None and hints.visible(): + # Hints list open: Up/Down navigate the command matches. + delta = -1 if event.key == "up" else 1 + if hints.move_highlight(delta): + event.stop() + event.prevent_default() + return + # Hints visible but nothing to move (single match) — fall through + # to TextArea's own cursor movement, NOT history. + elif self._maybe_history_nav(event.key): + # Empty field + no hints: Up/Down walk the message history. event.stop() event.prevent_default() return + # else fall through to TextArea (multiline cursor move) + else: + # Any non-Up/Down key (typing, submit, etc.) leaves history-browsing + # mode so further Up/Down start a fresh recall from the newest. + box = self._box_ref + if box is not None and box.browsing: + box.cancel_browsing() await super()._on_key(event) + def _maybe_history_nav(self, key: str) -> bool: + """Handle Up/Down as message-history recall. Returns True if consumed. + + Only enters history mode on ``Up`` when the field is empty; ``Down`` + only acts while already browsing (otherwise an empty field + Down is a + no-op left to TextArea). + """ + box = self._box_ref + if box is None: + return False + if key == "up": + if not box.browsing and self.text != "": + return False # non-empty field → multiline cursor, not history + value = box.history_up(self.text) + if value is None: + # Empty history, or already at the oldest entry — consume only + # while browsing (stay at the oldest), else let TextArea handle. + return box.browsing + self._set_history_text(value) + return True + else: # down + if not box.browsing: + return False # not browsing → TextArea cursor move + value = box.history_down() + if value is None: + return False + self._set_history_text(value) + return True + + def _set_history_text(self, value: str) -> None: + """Replace the field with a recalled history entry, cursor at the end.""" + self.text = value + self.move_cursor((0, len(self.text))) + def _submit(self) -> None: """Submit, but if a command hint is open, run the highlighted command.""" hints = self._hints_ref @@ -138,6 +193,14 @@ def __init__(self) -> None: super().__init__() + # Per-session message history (bash-style recall). In-memory only — the + # app records submitted plain messages and resets this on session + # switch. Capped at the most recent ``_HISTORY_CAP`` entries with + # consecutive-duplicate suppression. + self._history: list[str] = [] + self._history_index: int = 0 + self._browsing: bool = False + self._draft: str = "" self._hints = CommandHints() self._input = _PromptInput( text="", @@ -146,8 +209,79 @@ show_line_numbers=False, soft_wrap=True, hints=self._hints, + box=self, ) + @property + def browsing(self) -> bool: + """True while Up/Down are walking the history (between entering and the + Down-past-end that restores the draft).""" + return self._browsing + + def append_history(self, text: str) -> None: + """Record a submitted message (raw text the user typed). + + Slash commands and ``!`` shell invocations are NOT recorded — only + plain messages to the agent. Consecutive duplicates are suppressed; the + list is capped at the most recent ``_HISTORY_CAP`` entries. + """ + if not text.startswith("/") and not text.startswith("!"): + entry = text + if self._history and self._history[-1] == entry: + return # dedup consecutive identical + self._history.append(entry) + if len(self._history) > _HISTORY_CAP: + self._history = self._history[-_HISTORY_CAP:] + # Either way, a submit ends any in-progress browsing and parks the index + # past the newest entry so the next Up recalls the latest. + self._browsing = False + self._history_index = len(self._history) + + def history_up(self, current: str) -> str | None: + """Recall the previous (older) entry. Enter browsing on first call. + + Returns the entry to show, or None if history is empty / already at the + oldest (caller stays put). + """ + if not self._history: + return None + if not self._browsing: + self._browsing = True + self._draft = current + self._history_index = len(self._history) # past the newest + if self._history_index > 0: + self._history_index -= 1 + return self._history[self._history_index] + return None # already at the oldest — stay + + def history_down(self) -> str | None: + """Recall the next (newer) entry, or restore the draft past the newest. + + Returns the text to show (the draft, possibly empty, on past-end); None + only if not browsing (should not be called then). + """ + if not self._browsing: + return None + if self._history_index < len(self._history) - 1: + self._history_index += 1 + return self._history[self._history_index] + # Past the newest → restore the draft and leave browsing mode. + self._browsing = False + self._history_index = len(self._history) + return self._draft + + def cancel_browsing(self) -> None: + """Leave history-browsing mode (e.g. the user started typing).""" + self._browsing = False + self._history_index = len(self._history) + + def reset_history(self) -> None: + """Clear the per-session history (on session switch/resume).""" + self._history = [] + self._history_index = 0 + self._browsing = False + self._draft = "" + def compose(self) -> ComposeResult: yield self._hints yield self._input diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py index 73479db..5dfc126 100644 --- a/tests/clients/test_input_box.py +++ b/tests/clients/test_input_box.py @@ -233,4 +233,146 @@ await _set_text(pilot, "/h") # only "help" matches await pilot.press("enter") await pilot.pause() - assert invoked == [("help", "")] \ No newline at end of file + assert invoked == [("help", "")] + + +# ── message history (Up/Down recall) ──────────────────────────────────────── + + +def test_append_history_caps_and_dedups_consecutive() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + for i in range(15): + box.append_history(f"m{i}") + assert len(box._history) == 10 + assert box._history == [f"m{i}" for i in range(5, 15)] + # Consecutive duplicate is suppressed. + box.append_history("m14") + assert len(box._history) == 10 + assert box._history[-1] == "m14" + # A different entry after it IS recorded. + box.append_history("m14") + box.append_history("new") + assert box._history[-1] == "new" + + +def test_append_history_skips_commands_and_shell() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + box.append_history("/themes") + box.append_history("!ls -la") + box.append_history("hello agent") + box.append_history("/help") + assert box._history == ["hello agent"] + + +def test_history_up_down_navigation() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + for m in ("a", "b", "c"): + box.append_history(m) + # Up from empty → newest, then older, then stay at oldest. + assert box.history_up("") == "c" + assert box.history_up("c") == "b" + assert box.history_up("b") == "a" + assert box.history_up("a") is None # at oldest — stay put + # Down back toward newest, then past-end restores the draft (empty). + assert box.history_down() == "b" + assert box.history_down() == "c" + assert box.history_down() == "" + assert not box.browsing + + +def test_history_up_empty_history_returns_none() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + assert box.history_up("") is None + assert not box.browsing + + +def test_history_down_without_browsing_returns_none() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + box.append_history("a") + assert box.history_down() is None # not browsing — nothing to do + + +def test_reset_history_clears_state() -> None: + from clients.terminal.tui.widgets.input_box import InputBox + + box = InputBox() + box.append_history("a") + box.history_up("") # enter browsing + assert box.browsing + box.reset_history() + assert box._history == [] + assert not box.browsing + assert box._draft == "" + + +@pytest.mark.anyio +async def test_arrow_up_on_empty_recalls_last_message() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + box = pilot.app.query_one("InputBox") + box.append_history("previous message") + # Field is empty + focused + no hints → Up recalls history. + await pilot.press("up") + await pilot.pause() + assert box._input.text == "previous message" + assert box.browsing + + +@pytest.mark.anyio +async def test_arrow_down_past_newest_restores_empty_draft() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + box = pilot.app.query_one("InputBox") + box.append_history("msg") + await pilot.press("up") # fill "msg" + await pilot.pause() + assert box._input.text == "msg" + await pilot.press("down") # past newest → draft (empty) + await pilot.pause() + assert box._input.text == "" + assert not box.browsing + + +@pytest.mark.anyio +async def test_arrow_up_on_nonempty_does_not_recall() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + box = pilot.app.query_one("InputBox") + box.append_history("history item") + await _set_text(pilot, "typing") # non-empty field + await pilot.press("up") # → TextArea multiline cursor, NOT history recall + await pilot.pause() + assert box._input.text == "typing" + assert not box.browsing + + +@pytest.mark.anyio +async def test_submit_records_plain_message_to_history() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + box = pilot.app.query_one("InputBox") + await _set_text(pilot, "hello agent") + await pilot.press("enter") + await pilot.pause() + assert box._history == ["hello agent"] + + +@pytest.mark.anyio +async def test_submit_slash_command_not_recorded() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + box = pilot.app.query_one("InputBox") + await _set_text(pilot, "/help") + await pilot.press("enter") + await pilot.pause() + assert box._history == [] \ No newline at end of file