diff --git a/src/kimi_cli/ui/shell/echo.py b/src/kimi_cli/ui/shell/echo.py index 5e6bbdd7c..6b787bb26 100644 --- a/src/kimi_cli/ui/shell/echo.py +++ b/src/kimi_cli/ui/shell/echo.py @@ -1,17 +1,31 @@ from __future__ import annotations +import shutil + from kosong.message import Message +from rich.console import Group from rich.text import Text from kimi_cli.ui.shell.prompt import PROMPT_SYMBOL from kimi_cli.utils.message import message_stringify -def render_user_echo(message: Message) -> Text: +def _separator_line() -> Text: + """Return a dashed separator line that spans the full terminal width.""" + try: + width = shutil.get_terminal_size().columns + except OSError: + width = 80 + return Text("-" * width, style="grey50") + + +def render_user_echo(message: Message) -> Group: """Render a user message as literal shell transcript text.""" - return Text(f"{PROMPT_SYMBOL} {message_stringify(message)}") + user_line = Text(f"{PROMPT_SYMBOL} {message_stringify(message)}", style="#007AFF") + return Group(user_line, _separator_line()) -def render_user_echo_text(text: str) -> Text: +def render_user_echo_text(text: str) -> Group: """Render the local prompt text exactly as the user saw it in the buffer.""" - return Text(f"{PROMPT_SYMBOL} {text}") + user_line = Text(f"{PROMPT_SYMBOL} {text}", style="#007AFF") + return Group(user_line, _separator_line()) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index ede338e05..090f6e028 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -1500,6 +1500,7 @@ def _(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, style=Style.from_dict( { + "user-input": "fg:#007AFF", # User input text - bright blue "bottom-toolbar": "noreverse", "running-prompt-placeholder": "fg:#7c8594 italic", "running-prompt-separator": "fg:#4a5568", @@ -1589,6 +1590,9 @@ def _install_prompt_buffer_visibility(self) -> None: buffer_container.filter = buffer_container.filter & Condition( self._should_render_input_buffer ) + # Apply style class to input buffer + if hasattr(buffer_container, "content") and isinstance(buffer_container.content, Window): + buffer_container.content.style = "class:user-input" self._prompt_buffer_container = buffer_container def _should_show_slash_completion_menu(self) -> bool: diff --git a/tests/ui_and_conv/test_replay.py b/tests/ui_and_conv/test_replay.py index 5eb4b970a..c29972a6f 100644 --- a/tests/ui_and_conv/test_replay.py +++ b/tests/ui_and_conv/test_replay.py @@ -66,6 +66,15 @@ async def test_build_replay_turns_from_wire_keeps_steer_as_user_turn(tmp_path: P assert turns[1].n_steps == 2 +def _get_first_renderable_plain(text): + """Extract plain text from first renderable of a Group, or fallback to str.""" + from rich.console import Group + + if isinstance(text, Group) and text.renderables: + return getattr(text.renderables[0], "plain", str(text.renderables[0])) + return getattr(text, "plain", str(text)) + + @pytest.mark.asyncio async def test_replay_recent_history_falls_back_to_history_when_wire_misses_steer( tmp_path: Path, @@ -86,7 +95,7 @@ async def test_replay_recent_history_falls_back_to_history_when_wire_misses_stee monkeypatch.setattr( replay_module.console, "print", - lambda text: printed.append(getattr(text, "plain", str(text))), + lambda text: printed.append(_get_first_renderable_plain(text)), ) async def fake_visualize(*_args, **_kwargs) -> None: @@ -156,7 +165,7 @@ async def test_replay_recent_history_falls_back_to_history_when_duplicate_text_s monkeypatch.setattr( replay_module.console, "print", - lambda text: printed.append(getattr(text, "plain", str(text))), + lambda text: printed.append(_get_first_renderable_plain(text)), ) async def fake_visualize(*_args, **_kwargs) -> None: diff --git a/tests/ui_and_conv/test_shell_prompt_echo.py b/tests/ui_and_conv/test_shell_prompt_echo.py index 10328fed5..9e97f08af 100644 --- a/tests/ui_and_conv/test_shell_prompt_echo.py +++ b/tests/ui_and_conv/test_shell_prompt_echo.py @@ -1,5 +1,5 @@ from kosong.message import Message -from rich.text import Text +from rich.console import Group import kimi_cli.ui.shell as shell_module from kimi_cli.ui.shell import Shell @@ -19,16 +19,18 @@ def _make_user_input(command: str, *, mode: PromptMode = PromptMode.AGENT) -> Us def test_echo_agent_input_prints_stringified_user_message(monkeypatch) -> None: - printed: list[Text] = [] + printed: list[Group] = [] monkeypatch.setattr(shell_module.console, "print", lambda text: printed.append(text)) Shell._echo_agent_input(_make_user_input("hi")) - assert [text.plain for text in printed] == ["✨ hi"] + assert len(printed) == 1 + # Group contains: user line + separator line + assert printed[0].renderables[0].plain == "✨ hi" def test_echo_agent_input_uses_display_command_for_placeholders(monkeypatch) -> None: - printed: list[Text] = [] + printed: list[Group] = [] monkeypatch.setattr(shell_module.console, "print", lambda text: printed.append(text)) user_input = UserInput( @@ -40,13 +42,16 @@ def test_echo_agent_input_uses_display_command_for_placeholders(monkeypatch) -> Shell._echo_agent_input(user_input) - assert [text.plain for text in printed] == ["✨ [Pasted text #1 +3 lines]"] + assert len(printed) == 1 + # Group contains: user line + separator line + assert printed[0].renderables[0].plain == "✨ [Pasted text #1 +3 lines]" def test_render_user_echo_preserves_literal_brackets() -> None: rendered = render_user_echo(Message(role="user", content=[TextPart(text="[brackets]")])) - assert rendered.plain == "✨ [brackets]" + # Group contains: user line + separator line + assert rendered.renderables[0].plain == "✨ [brackets]" def test_render_user_echo_preserves_image_placeholder_literal() -> None: @@ -57,7 +62,8 @@ def test_render_user_echo_preserves_image_placeholder_literal() -> None: ) ) - assert rendered.plain == "✨ [image]" + # Group contains: user line + separator line + assert rendered.renderables[0].plain == "✨ [image]" def test_render_user_echo_preserves_audio_placeholder_literal() -> None: @@ -72,7 +78,8 @@ def test_render_user_echo_preserves_audio_placeholder_literal() -> None: ) ) - assert rendered.plain == "✨ [audio:clip]" + # Group contains: user line + separator line + assert rendered.renderables[0].plain == "✨ [audio:clip]" def test_render_user_echo_preserves_video_placeholder_literal() -> None: @@ -85,7 +92,8 @@ def test_render_user_echo_preserves_video_placeholder_literal() -> None: ) ) - assert rendered.plain == "✨ [video]" + # Group contains: user line + separator line + assert rendered.renderables[0].plain == "✨ [video]" def test_render_user_echo_preserves_mixed_content_order() -> None: @@ -101,7 +109,8 @@ def test_render_user_echo_preserves_mixed_content_order() -> None: ) ) - assert rendered.plain == "✨ look [image][audio][video]" + # Group contains: user line + separator line + assert rendered.renderables[0].plain == "✨ look [image][audio][video]" def test_should_echo_agent_input_for_plain_agent_message() -> None: diff --git a/tests/ui_and_conv/test_shell_run_placeholders.py b/tests/ui_and_conv/test_shell_run_placeholders.py index cd3a529f2..19b521be3 100644 --- a/tests/ui_and_conv/test_shell_run_placeholders.py +++ b/tests/ui_and_conv/test_shell_run_placeholders.py @@ -67,6 +67,15 @@ def _make_fake_soul(): ) +def _get_first_renderable_plain(text): + """Extract plain text from first renderable of a Group, or fallback to str.""" + from rich.console import Group + + if isinstance(text, Group) and text.renderables: + return getattr(text.renderables[0], "plain", str(text.renderables[0])) + return getattr(text, "plain", str(text)) + + @pytest.fixture def _patched_shell_run(monkeypatch): _FakePromptSession.instances = [] @@ -81,7 +90,7 @@ def _patched_shell_run(monkeypatch): monkeypatch.setattr( shell_module.console, "print", - lambda text="": printed.append(getattr(text, "plain", str(text))), + lambda text="": printed.append(_get_first_renderable_plain(text)), ) return printed diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 2e96db1c6..86daedf8c 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -7,11 +7,20 @@ import pytest from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document +from rich.console import Group from rich.text import Text from kimi_cli.ui.shell.prompt import PromptMode, UserInput from kimi_cli.wire.types import ApprovalRequest, StatusUpdate, SteerInput, TextPart + +def _get_first_renderable_plain(text): + """Extract plain text from first renderable of a Group, or fallback to str.""" + if isinstance(text, Group) and text.renderables: + return getattr(text.renderables[0], "plain", str(text.renderables[0])) + return getattr(text, "plain", str(text)) + + shell_visualize = importlib.import_module("kimi_cli.ui.shell.visualize") _LiveView = shell_visualize._LiveView _PromptLiveView = shell_visualize._PromptLiveView @@ -157,7 +166,7 @@ def test_live_view_renders_steer_input_as_user_echo(monkeypatch) -> None: monkeypatch.setattr( shell_visualize.console, "print", - lambda text: printed.append(getattr(text, "plain", str(text))), + lambda text: printed.append(_get_first_renderable_plain(text)), ) view.dispatch_wire_message(SteerInput(user_input=[TextPart(text="A steer follow-up")])) @@ -175,7 +184,7 @@ def test_live_view_flushes_current_output_before_printing_steer_input(monkeypatc monkeypatch.setattr( shell_visualize.console, "print", - lambda text: order.append(("print", getattr(text, "plain", str(text)))), + lambda text: order.append(("print", _get_first_renderable_plain(text))), ) view.dispatch_wire_message(SteerInput(user_input=[TextPart(text="A steer follow-up")])) @@ -499,7 +508,7 @@ def test_handle_local_input_echoes_placeholder_display_text_but_steers_expanded_ monkeypatch.setattr( shell_visualize.console, "print", - lambda text: printed.append(getattr(text, "plain", str(text))), + lambda text: printed.append(_get_first_renderable_plain(text)), ) view.handle_local_input(