Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/kimi_cli/ui/shell/echo.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 4 additions & 0 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions tests/ui_and_conv/test_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 19 additions & 10 deletions tests/ui_and_conv/test_shell_prompt_echo.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion tests/ui_and_conv/test_shell_run_placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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

Expand Down
15 changes: 12 additions & 3 deletions tests/ui_and_conv/test_visualize_running_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")]))
Expand All @@ -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")]))
Expand Down Expand 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(
Expand Down