diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index 5b42cdf..2c1fe7f 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -1,16 +1,47 @@ -"""Input box widget for the TUI.""" +"""Input box widget for the TUI. + +A multi-line prompt: ``Enter`` sends the message, ``Ctrl+Enter`` inserts a hard +line break, and long lines soft-wrap visually (``TextArea`` defaults to +``soft_wrap=True``). The field grows with content up to ``max-height`` and then +scrolls internally. +""" from __future__ import annotations +from textual import events from textual.app import ComposeResult from textual.containers import Vertical -from textual.widgets import Input +from textual.widgets import TextArea from clients.terminal.tui.events import UserSubmitted +class _PromptInput(TextArea): + """Multi-line text area that submits on Enter and breaks on Ctrl+Enter.""" + + async def _on_key(self, event: events.Key) -> None: + # Intercept before TextArea's own _on_key, which maps "enter" -> "\n". + if event.key == "enter": + event.stop() + event.prevent_default() + self.action_submit() + return + if event.key == "ctrl+enter": + event.stop() + event.prevent_default() + self.insert("\n") + return + await super()._on_key(event) + + def action_submit(self) -> None: + text = self.text + if text.strip(): + self.post_message(UserSubmitted(text)) + self.text = "" + + class InputBox(Vertical): - """Bottom prompt frame with input field.""" + """Bottom prompt frame with a multi-line input field.""" DEFAULT_CSS = """ InputBox { @@ -21,35 +52,31 @@ color: $tui-text; padding: 0 1; } - InputBox > Input { - height: 3; + InputBox > TextArea { + height: auto; + min-height: 3; + max-height: 12; width: 100%; border: none; background: $tui-surface; color: $tui-text; padding: 0; } - InputBox > Input:focus { + InputBox > TextArea:focus { border: none; background-tint: transparent; } - InputBox > Input > .input--placeholder { - color: $tui-text-dim; - } - InputBox > Input > .input--cursor { - background: $tui-text; - color: $tui-background; - text-style: bold; - } - InputBox > Input > .input--selection { - background: $tui-selection; - color: $tui-background; - } """ def __init__(self) -> None: super().__init__() - self._input = Input(placeholder="Ask anything...", classes="input-field") + self._input = _PromptInput( + text="", + placeholder="Ask anything... (Enter to send, Ctrl+Enter for a new line)", + classes="input-field", + show_line_numbers=False, + soft_wrap=True, + ) def compose(self) -> ComposeResult: yield self._input @@ -57,20 +84,8 @@ def on_mount(self) -> None: self._input.focus() - def on_input_changed(self, event: Input.Changed) -> None: - self._input.refresh(layout=False) - - def on_input_submitted(self, event: Input.Submitted) -> None: - text = event.value.strip() - if text: - self.post_message(UserSubmitted(text)) - self._input.value = "" - def focus_input(self) -> None: self._input.focus() def set_placeholder(self, text: str) -> None: - self._input.placeholder = text - - def set_prompt_char(self, char: str) -> None: - self._prompt.update(char) + self._input.placeholder = text \ No newline at end of file diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 0b4201d..8e9fdb7 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -68,7 +68,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "hello" + input_box._input.text = "hello" await pilot.press("enter") await pilot.pause() chat = pilot.app.query_one("ChatPanel") @@ -86,7 +86,7 @@ input_box._input.focus() await pilot.press("h", "i", " ", "n", "a", "v", "i") await pilot.pause() - assert input_box._input.value == "hi navi" + assert input_box._input.text == "hi navi" strip = input_box._input.render_line(0) segments = list(strip) rendered_text = "".join(seg.text for seg in segments) @@ -98,6 +98,59 @@ @pytest.mark.anyio +async def test_ctrl_enter_inserts_newline_without_submitting() -> None: + """Ctrl+Enter inserts a hard line break; Enter is what submits.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.focus() + await pilot.press("h", "i") + await pilot.press("ctrl+enter") + await pilot.press("t", "h", "e", "r", "e") + await pilot.pause() + # A newline was inserted, not submitted. + assert input_box._input.text == "hi\nthere" + chat = pilot.app.query_one("ChatPanel") + assert not any( + item.kind == "user_message" for item in chat._model.items + ) + + +@pytest.mark.anyio +async def test_enter_submits_multiline_text() -> None: + """Enter submits the full multi-line buffer (newlines preserved).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.text = "line one\nline two" + input_box._input.focus() + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + submitted = [ + item.content + for item in chat._model.items + if item.kind == "user_message" + ] + assert submitted == ["line one\nline two"] + # Input is cleared after submit. + assert input_box._input.text == "" + + +@pytest.mark.anyio +async def test_input_grows_with_multiline_content() -> None: + """The input field height grows with content (multi-line), up to a cap.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + single = input_box._input.region.height + input_box._input.text = "a\nb\nc\nd\ne\nf" + await pilot.pause() + grown = input_box._input.region.height + assert grown > single + + +@pytest.mark.anyio async def test_ws_event_renders_in_chat() -> None: """A synthetic WebSocket stream_delta is added to the assistant response.""" async with NaviCodeTui(new_session=True).run_test() as pilot: @@ -187,7 +240,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/notacommand" + input_box._input.text = "/notacommand" await pilot.press("enter") await pilot.pause() chat = pilot.app.query_one("ChatPanel") diff --git a/tests/clients/test_tui_export.py b/tests/clients/test_tui_export.py index 6f9eac6..d97ce0e 100644 --- a/tests/clients/test_tui_export.py +++ b/tests/clients/test_tui_export.py @@ -75,7 +75,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/export" + input_box._input.text = "/export" await pilot.press("enter") await pilot.pause() @@ -110,7 +110,7 @@ await pilot.pause() pilot.app._ctx.session_id = None input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/export" + input_box._input.text = "/export" await pilot.press("enter") await pilot.pause() chat = pilot.app.query_one("ChatPanel") @@ -133,7 +133,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/export" + input_box._input.text = "/export" await pilot.press("enter") await pilot.pause() chat = pilot.app.query_one("ChatPanel") diff --git a/tests/clients/test_tui_themes.py b/tests/clients/test_tui_themes.py index ae134b7..c2749f2 100644 --- a/tests/clients/test_tui_themes.py +++ b/tests/clients/test_tui_themes.py @@ -29,7 +29,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/themes" + input_box._input.text = "/themes" await pilot.press("enter") await pilot.pause() assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" @@ -40,7 +40,7 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/themes" + input_box._input.text = "/themes" await pilot.press("enter") await pilot.pause() assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" @@ -59,7 +59,7 @@ tui_settings = get_tui_settings() original = tui_settings.mouse input_box = pilot.app.query_one("InputBox") - input_box._input.value = "/mouse" + input_box._input.text = "/mouse" await pilot.press("enter") await pilot.pause() assert tui_settings.mouse is not original