"""Integration tests for the Textual app via the pilot harness. Uses a fake in-process session so no real model is invoked. Exercises the full loop: keypress -> submit -> worker thread -> stream_actions -> reducer -> state. """ import asyncio import threading import time import pytest from textual import events from textual.containers import VerticalScroll from deerflow.client import StreamEvent from deerflow.tui.app import DeerFlowTUI from deerflow.tui.cli import LaunchPlan from deerflow.tui.view_state import SystemMessage class _FakeClient: def __init__(self): self.stream_calls: list[tuple] = [] def list_models(self): return {"models": [{"name": "fake-model", "display_name": "Fake Model"}]} def list_skills(self, enabled_only=False): return {"skills": [{"name": "tdd", "enabled": True}]} def stream(self, message, *, thread_id=None, **kwargs): self.stream_calls.append((message, thread_id, kwargs)) yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello ", "id": "m1"}) yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "world", "id": "m1"}) yield StreamEvent(type="end", data={"usage": {"total_tokens": 3}}) class _FakeSession: def __init__(self): self.client = _FakeClient() def resolve_thread(self, plan): return None async def _wait_until(predicate, pilot, *, timeout=3.0): deadline = 0.0 while deadline < timeout: await pilot.pause() if predicate(): return True await asyncio.sleep(0.02) deadline += 0.02 return predicate() async def _fill_scrollable_transcript(app: DeerFlowTUI, pilot) -> VerticalScroll: for index in range(80): app._dispatch(SystemMessage(f"row {index}: " + "content " * 12)) await pilot.pause() scroll = app.query_one("#scroll", VerticalScroll) assert scroll.max_scroll_y > 0 assert scroll.is_vertical_scroll_end return scroll @pytest.mark.asyncio async def test_app_runs_a_turn_and_renders_streamed_assistant(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until( lambda: not app._streaming and any(r.kind == "assistant" for r in app.state.rows), pilot, ) kinds = [r.kind for r in app.state.rows] assert "user" in kinds assert "assistant" in kinds assistant = [r for r in app.state.rows if r.kind == "assistant"][-1] assert assistant.text == "Hello world" assert app.state.usage == {"total_tokens": 3} @pytest.mark.asyncio @pytest.mark.parametrize("prefix", ["", "/tdd\n", "/tdd\t"]) async def test_multiline_paste_reaches_agent_without_dropping_lines(prefix): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) pasted = prefix + 'Traceback:\n File "worker.py", line 7\nRuntimeError: boom' async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste(pasted)) await pilot.pause() await pilot.press("enter") assert await _wait_until(lambda: bool(session.client.stream_calls), pilot) assert session.client.stream_calls[0][0] == pasted @pytest.mark.asyncio @pytest.mark.parametrize("separator", [" ", "\t", "\n", "\u00a0", "\u2003", "\u202f", "\u3000"]) async def test_pasted_whitespace_closes_palette_and_enter_submits_skill(separator): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) pasted = f"/tdd{separator}" async with app.run_test() as pilot: await pilot.pause() await pilot.press("slash", "t", "d", "d") assert app._palette_open app.post_message(events.Paste(separator)) await pilot.pause() assert app.query_one("#composer").value == pasted palette_open_after_separator = app._palette_open await pilot.press("enter") assert await _wait_until(lambda: bool(session.client.stream_calls), pilot) assert not palette_open_after_separator assert not app._palette_open assert len(session.client.stream_calls) == 1 assert session.client.stream_calls[0][0] == "/tdd" assert app._history.entries() == ["/tdd"] @pytest.mark.asyncio async def test_multiline_arrows_move_lines_before_input_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._history.add("previous prompt") app.post_message(events.Paste("first line\nsecond line")) await pilot.pause() composer = app.query_one("#composer") assert composer.cursor_location == (1, 11) await pilot.press("up") await pilot.pause() assert composer.value == "first line\nsecond line" assert composer.cursor_location[0] == 0 await pilot.press("down") await pilot.pause() assert composer.value == "first line\nsecond line" assert composer.cursor_location[0] == 1 composer.cursor_position = 0 await pilot.press("up") await pilot.pause() assert composer.value == "previous prompt" @pytest.mark.asyncio @pytest.mark.parametrize("draft", ["unsent question", "first line\nsecond line"]) async def test_up_with_empty_history_preserves_draft_and_cursor(draft): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste(draft)) await pilot.pause() composer = app.query_one("#composer") location = (0, 2) composer.move_cursor(location) await pilot.press("up", "up") await pilot.pause() assert composer.value == draft assert composer.cursor_location == location @pytest.mark.asyncio async def test_up_with_empty_history_preserves_undo(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste("unsent question")) await pilot.pause() await pilot.press("up", "ctrl+z") await pilot.pause() assert app.query_one("#composer").value == "" @pytest.mark.asyncio async def test_app_assigns_thread_id_on_first_send(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() assert app._conv_thread_id is None await pilot.press("y", "o") await pilot.press("enter") await _wait_until(lambda: app._conv_thread_id is not None, pilot) assert app._conv_thread_id is not None @pytest.mark.asyncio async def test_help_command_renders_system_row_without_calling_agent(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() for ch in "/help": await pilot.press(ch) await pilot.press("enter") await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot) assert any(r.kind == "system" for r in app.state.rows) assert any(r.kind == "system" and "/clear" in r.text for r in app.state.rows) # Commands previously missing from the hardcoded help string must now appear, # since the help text is derived from the command registry. help_rows = [r for r in app.state.rows if r.kind == "system"] for command in ("/help", "/resume", "/switch", "/uploads", "/artifacts", "/details"): assert any(command in r.text for r in help_rows), command # /help must not produce a user turn or start streaming. assert not any(r.kind == "user" for r in app.state.rows) @pytest.mark.asyncio async def test_help_text_matches_command_registry(): from deerflow.tui.command_registry import format_command_help app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() for ch in "/help": await pilot.press(ch) await pilot.press("enter") await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot) expected = format_command_help() assert any(r.kind == "system" and expected in r.text for r in app.state.rows) @pytest.mark.asyncio async def test_clear_command_clears_display_without_resetting_thread_or_calling_agent(): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._conv_thread_id = "thread-123" app._dispatch(SystemMessage("visible row")) await pilot.pause() app._handle_submit("/clear") await pilot.pause() assert app.state.rows == () assert app._conv_thread_id == "thread-123" assert session.client.stream_calls == [] @pytest.mark.asyncio async def test_up_arrow_recalls_previous_input_from_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() for ch in "remember me": await pilot.press("space" if ch == " " else ch) await pilot.press("enter") await _wait_until(lambda: any(r.kind == "user" for r in app.state.rows), pilot) # Composer is empty after submit; Up should recall the last entry. await pilot.press("up") await pilot.pause() assert app.query_one("#composer").value == "remember me" @pytest.mark.asyncio async def test_escape_interrupts_an_active_run(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._streaming = True app._refresh_status() await pilot.press("escape") await pilot.pause() assert app._streaming is False assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows) # --------------------------------------------------------------------------- # # /quit vs. Ctrl+C during an active stream # --------------------------------------------------------------------------- # class _BlockedClient(_FakeClient): """A client whose stream() blocks mid-run until the test releases it. Unlike ``_FakeClient``, which finishes a turn synchronously, this lets a test catch the app while a real worker thread is genuinely stuck inside the streaming generator — the same shape as a live agent turn — instead of only flipping the ``_streaming`` flag by hand. """ def __init__(self): self.release = threading.Event() def stream(self, message, *, thread_id=None, **kwargs): self.release.wait(timeout=5) yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "done", "id": "m1"}) yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}}) class _BlockedSession(_FakeSession): def __init__(self): self.client = _BlockedClient() @pytest.mark.asyncio async def test_clear_command_is_blocked_during_active_stream(): session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming, pilot) app._handle_submit("/clear") await pilot.pause() assert app._streaming is True assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows) assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows) session.client.release.set() await _wait_until(lambda: not app._streaming, pilot) @pytest.mark.asyncio async def test_new_command_is_blocked_during_active_stream(): session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming and app._conv_thread_id is not None, pilot) active_thread_id = app._conv_thread_id app._handle_submit("/new") await pilot.pause() assert app._streaming is True assert app._conv_thread_id == active_thread_id assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows) assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows) assert not any(r.kind == "system" and "Started a new thread" in r.text for r in app.state.rows) session.client.release.set() await _wait_until(lambda: not app._streaming, pilot) @pytest.mark.asyncio async def test_quit_interrupts_an_active_stream_before_exiting(): """/quit during a run must interrupt first, mirroring Ctrl+C. Regression test: ``_handle_builtin``'s "quit" branch used to call ``self.exit()`` unconditionally, unlike ``action_interrupt`` (Ctrl+C), which checks ``self._streaming`` and interrupts before any teardown. Left unfixed, the worker thread survives the app's exit; its next ``call_from_thread`` call then fails silently and the in-flight turn is abandoned without a trace. The interrupted worker is still inside its step, so the app says it is waiting and exits once that step returns. """ session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: try: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming, pilot) for ch in "/quit": await pilot.press(ch) await pilot.press("enter") await pilot.pause() assert app._streaming is False assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows) assert any(r.kind == "system" and "Press Ctrl+C again to quit now" in r.text for r in app.state.rows) assert app._exit is False session.client.release.set() assert await _wait_until(lambda: app._exit, pilot) finally: session.client.release.set() @pytest.mark.asyncio async def test_ctrl_c_interrupts_an_active_stream_without_exiting(): """Contrast/control: Ctrl+C on a real blocked worker interrupts but stays open.""" session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming, pilot) await pilot.press("ctrl+c") await pilot.pause() assert app._streaming is False assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows) assert app._exit is False session.client.release.set() @pytest.mark.asyncio async def test_ctrl_c_quit_waits_for_the_interrupted_run_and_refuses_new_sends(): session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: try: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming, pilot) await pilot.press("ctrl+c") # interrupt # Another thread is free to send to, but not while quitting: the # exit would cut that new run off as soon as the old one stops. app._handle_submit("/new") await pilot.press("ctrl+c") # quit await pilot.pause() assert app._exit is False assert any(r.kind == "system" and "Press Ctrl+C again to quit now" in r.text for r in app.state.rows) app.query_one("#composer").value = "one more" await pilot.press("enter") await pilot.pause() assert not app._streaming assert app.query_one("#composer").value == "one more" assert not any(r.kind == "user" and r.text == "one more" for r in app.state.rows) session.client.release.set() assert await _wait_until(lambda: app._exit, pilot) finally: session.client.release.set() @pytest.mark.asyncio async def test_second_quit_request_exits_without_waiting_for_the_interrupted_run(): session = _BlockedSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: try: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") await _wait_until(lambda: app._streaming, pilot) await pilot.press("ctrl+c") # interrupt await pilot.press("ctrl+c") # quit: waits await pilot.press("ctrl+c") # quit now await pilot.pause() assert app._exit is True assert app._run.worker_running() finally: session.client.release.set() @pytest.mark.asyncio async def test_quit_waits_for_a_completed_run_still_saving_its_title(): class _BlockingTitleWriter: def __init__(self): self.writing = threading.Event() self.release = threading.Event() self.titles = [] def ensure_created(self, *args, **kwargs): pass def set_title(self, thread_id, title): self.writing.set() self.release.wait(5) self.titles.append(title) class _TitledClient(_FakeClient): def stream(self, message, *, thread_id=None, **kwargs): yield StreamEvent(type="values", data={"title": "Saved title"}) yield StreamEvent(type="end", data={"usage": {}}) session = _FakeSession() session.client = _TitledClient() session.writer = _BlockingTitleWriter() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: try: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") assert await _wait_until(lambda: session.writer.writing.is_set() and not app._streaming, pilot) await pilot.press("ctrl+c") await pilot.pause() assert app._exit is False session.writer.release.set() assert await _wait_until(lambda: app._exit, pilot) assert session.writer.titles == ["Saved title"] finally: session.writer.release.set() @pytest.mark.asyncio async def test_unexpected_worker_failure_ends_the_run_with_an_error_row(): class _FailingWriter: def ensure_created(self, *args, **kwargs): raise RuntimeError("thread store exploded") session = _FakeSession() session.writer = _FailingWriter() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() await pilot.press("h", "i") await pilot.press("enter") assert await _wait_until(lambda: not app._streaming, pilot) assert any(r.kind == "assistant" and r.error and "thread store exploded" in r.text for r in app.state.rows) assert app._run.stopped.is_set() assert session.client.stream_calls == [] _FORCE_QUIT_SCRIPT = """ import threading, time from deerflow.client import StreamEvent from deerflow.tui.app import DeerFlowTUI from deerflow.tui.cli import LaunchPlan from deerflow.tui.session import Session in_step = threading.Event() class Client: def list_models(self): return {"models": []} def list_skills(self, **kwargs): return {"skills": []} def stream(self, message, *, thread_id=None, **kwargs): yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "working", "id": "m1"}) in_step.set() time.sleep(60) # a long tool step that emits no event yield StreamEvent(type="end", data={"usage": {}}) class App(DeerFlowTUI): def on_mount(self): super().on_mount() self._send_to_agent("q") self.set_interval(0.05, self._press_ctrl_c_once_in_step) def _press_ctrl_c_once_in_step(self): if in_step.is_set() and not self._exit: for _ in range(3): # interrupt, quit, quit now self.action_interrupt() App(Session(client=Client(), writer=None), LaunchPlan(mode="tui", thread_id="t-1")).run(headless=True) print("exited") """ def test_quit_now_exits_the_process_while_the_interrupted_step_is_still_running(): """Drive the real ``App.run()``: a Textual thread worker would be joined by asyncio's default-executor shutdown and again at interpreter exit, holding the process until the 60 s step ended.""" import subprocess import sys started = time.monotonic() result = subprocess.run([sys.executable, "-c", _FORCE_QUIT_SCRIPT], capture_output=True, text=True, timeout=45) assert result.returncode == 0, result.stderr assert result.stdout.strip().endswith("exited") assert time.monotonic() - started < 30 @pytest.mark.asyncio async def test_tab_keeps_focus_on_composer_when_palette_closed(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() composer = app.query_one("#composer") assert app.focused is composer await pilot.press("tab") await pilot.pause() # Tab must not move focus off the composer to the scroll region. assert app.focused is composer @pytest.mark.asyncio async def test_page_keys_scroll_transcript_without_moving_composer_focus(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() composer = app.query_one("#composer") scroll = await _fill_scrollable_transcript(app, pilot) bottom = scroll.scroll_y await pilot.press("pageup") await pilot.pause() assert scroll.scroll_y < bottom assert app.focused is composer for _ in range(3): if scroll.is_vertical_scroll_end: break await pilot.press("pagedown") await pilot.pause() assert scroll.is_vertical_scroll_end assert app.focused is composer @pytest.mark.asyncio async def test_transcript_update_does_not_cancel_pending_page_scroll(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() scroll = await _fill_scrollable_transcript(app, pilot) bottom = scroll.scroll_y app.action_transcript_page_up() app._dispatch(SystemMessage("output before the queued scroll is applied")) await pilot.pause() assert scroll.scroll_y < bottom assert not scroll.is_vertical_scroll_end @pytest.mark.asyncio async def test_transcript_refresh_preserves_manual_scroll_until_returning_to_end(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() scroll = await _fill_scrollable_transcript(app, pilot) await pilot.press("pageup") await pilot.pause() reading_position = scroll.scroll_y assert not scroll.is_vertical_scroll_end app._dispatch(SystemMessage("new streamed output")) await pilot.pause() assert scroll.scroll_y == reading_position assert not scroll.is_vertical_scroll_end for _ in range(3): if scroll.is_vertical_scroll_end: break await pilot.press("pagedown") await pilot.pause() assert scroll.is_vertical_scroll_end app._dispatch(SystemMessage("new output after returning to the end")) await pilot.pause() assert scroll.is_vertical_scroll_end @pytest.mark.asyncio async def test_transcript_refresh_preserves_non_key_scroll_position(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() scroll = await _fill_scrollable_transcript(app, pilot) scroll.scroll_page_up(animate=False) await pilot.pause() reading_position = scroll.scroll_y assert not scroll.is_vertical_scroll_end app._dispatch(SystemMessage("new output after external scrolling")) await pilot.pause() assert scroll.scroll_y == reading_position @pytest.mark.asyncio async def test_unknown_command_shows_error_system_row(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() for ch in "/nope": await pilot.press(ch) await pilot.press("enter") await _wait_until( lambda: any(r.kind == "system" and getattr(r, "tone", "") == "error" for r in app.state.rows), pilot, ) assert any(r.kind == "system" and r.tone == "error" for r in app.state.rows) # --------------------------------------------------------------------------- # # /goal handler # --------------------------------------------------------------------------- # class _GoalClient(_FakeClient): """Records goal API calls and keeps an in-memory active goal.""" def __init__(self): self.calls: list[tuple] = [] self.goal: dict | None = None def get_goal(self, thread_id): self.calls.append(("get", thread_id)) return {"goal": self.goal} def set_goal(self, thread_id, objective): self.calls.append(("set", thread_id, objective)) self.goal = {"objective": objective, "status": "active"} return {"goal": self.goal} def clear_goal(self, thread_id): self.calls.append(("clear", thread_id)) self.goal = None return {"goal": None} class _GoalSession(_FakeSession): def __init__(self): self.client = _GoalClient() def _system_rows(app): return [r for r in app.state.rows if r.kind == "system"] @pytest.mark.asyncio async def test_goal_set_mints_thread_and_reports_objective(): session = _GoalSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() assert app._conv_thread_id is None app._handle_goal("finish the work") await pilot.pause() assert app._conv_thread_id is not None assert ("set", app._conv_thread_id, "finish the work") in session.client.calls assert any("Goal set: finish the work" in r.text for r in _system_rows(app)) @pytest.mark.asyncio async def test_goal_status_without_thread_reports_no_active_goal(): session = _GoalSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._handle_goal("") await pilot.pause() # No thread yet -> no gateway round-trip. assert session.client.calls == [] assert any(r.text == "No active goal." for r in _system_rows(app)) @pytest.mark.asyncio async def test_goal_status_reports_active_objective(): session = _GoalSession() session.client.goal = {"objective": "ship it", "status": "active"} app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._conv_thread_id = "t-1" app._handle_goal("") await pilot.pause() assert ("get", "t-1") in session.client.calls assert any("Goal: ship it" in r.text for r in _system_rows(app)) @pytest.mark.asyncio async def test_goal_clear_calls_gateway_and_confirms(): session = _GoalSession() session.client.goal = {"objective": "ship it", "status": "active"} app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._conv_thread_id = "t-1" app._handle_goal("clear") await pilot.pause() assert ("clear", "t-1") in session.client.calls assert any(r.text == "Goal cleared." for r in _system_rows(app)) @pytest.mark.asyncio async def test_goal_set_failure_shows_error_tone(): class _Boom(_GoalClient): def set_goal(self, thread_id, objective): raise RuntimeError("gateway down") session = _GoalSession() session.client = _Boom() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._conv_thread_id = "t-1" app._handle_goal("do it") await pilot.pause() errors = [r for r in _system_rows(app) if r.tone == "error"] assert any("Could not set goal." in r.text for r in errors) @pytest.mark.asyncio async def test_multiline_paste_keeps_leading_indentation_and_trailing_newline(): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) pasted = " def f():\n return 1\n" async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste(pasted)) await pilot.pause() await pilot.press("enter") await _wait_until(lambda: bool(session.client.stream_calls), pilot) assert session.client.stream_calls[0][0] == pasted assert app._history.entries() == [pasted] @pytest.mark.asyncio async def test_whitespace_only_input_is_not_submitted(): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste(" \n\t\n")) await pilot.pause() await pilot.press("enter") await pilot.pause() assert session.client.stream_calls == [] assert app._history.entries() == [] @pytest.mark.asyncio async def test_message_refused_during_a_run_stays_in_the_composer(): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) pasted = " def f():\n return 1\n" async with app.run_test() as pilot: await pilot.pause() app._streaming = True app.post_message(events.Paste(pasted)) await pilot.pause() await pilot.press("enter") await pilot.pause() composer = app.query_one("#composer") assert composer.value == pasted assert composer.cursor_position == len(pasted) assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows) assert session.client.stream_calls == [] app._streaming = False await pilot.press("enter") await _wait_until(lambda: bool(session.client.stream_calls), pilot) assert composer.value == "" assert session.client.stream_calls[0][0] == pasted assert app._history.entries() == [pasted] @pytest.mark.asyncio async def test_refused_bare_skill_activation_is_restored_without_reopening_the_palette(): """Restoring "/tdd" must not reopen the palette, or Enter would fill it instead of resending.""" session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._streaming = True await pilot.press("/", "t", "d", "d", "escape", "enter") await pilot.pause() composer = app.query_one("#composer") assert composer.value == "/tdd" assert app._palette_open is False app._streaming = False await pilot.press("enter") await _wait_until(lambda: bool(session.client.stream_calls), pilot) assert composer.value == "" assert session.client.stream_calls[0][0] == "/tdd" @pytest.mark.asyncio async def test_editing_a_restored_slash_draft_reopens_the_palette(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._streaming = True await pilot.press("/", "t", "d", "d", "escape", "enter") await pilot.pause() await pilot.press("backspace") await pilot.pause() assert app.query_one("#composer").value == "/td" assert app._palette_open is True # Once edited, the draft is ordinary input again, even if retyped back. await pilot.press("d") await pilot.pause() assert app.query_one("#composer").value == "/tdd" assert app._palette_open is True @pytest.mark.asyncio async def test_refused_skill_activation_is_restored_as_recorded_in_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._streaming = True app.post_message(events.Paste(" /tdd write tests ")) await pilot.pause() await pilot.press("enter") await pilot.pause() assert app.query_one("#composer").value == "/tdd write tests" assert app._history.entries() == ["/tdd write tests"] @pytest.mark.asyncio async def test_down_on_last_line_of_multiline_input_falls_back_to_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._history.add("first line\nsecond line") await pilot.press("up") await pilot.pause() composer = app.query_one("#composer") assert composer.value == "first line\nsecond line" assert composer.cursor_location[0] == 1 # Already on the last document line: Down leaves the recalled entry for the draft. await pilot.press("down") await pilot.pause() assert composer.value == "" @pytest.mark.asyncio @pytest.mark.parametrize("history", [[], ["previous prompt"]]) @pytest.mark.parametrize("draft", ["unsent question", "first line\nsecond line"]) async def test_down_without_history_navigation_preserves_draft_and_cursor(history, draft): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() for entry in history: app._history.add(entry) app.post_message(events.Paste(draft)) await pilot.pause() composer = app.query_one("#composer") location = (len(draft.splitlines()) - 1, 2) composer.move_cursor(location) await pilot.press("down", "down") await pilot.pause() assert composer.value == draft assert composer.cursor_location == location await pilot.press("ctrl+z") await pilot.pause() assert composer.value == "" @pytest.mark.asyncio async def test_history_round_trip_restores_draft_and_idle_down_keeps_later_edits(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) draft = "first line\nsecond line" async with app.run_test() as pilot: await pilot.pause() app._history.add("older prompt") app._history.add("newer prompt") app.post_message(events.Paste(draft)) await pilot.pause() composer = app.query_one("#composer") composer.cursor_position = 0 await pilot.press("up") await pilot.pause() assert composer.value == "newer prompt" await pilot.press("up") await pilot.pause() assert composer.value == "older prompt" await pilot.press("down") await pilot.pause() assert composer.value == "newer prompt" await pilot.press("down") await pilot.pause() assert composer.value == draft app.post_message(events.Paste(" edited")) await pilot.pause() await pilot.press("down") await pilot.pause() assert composer.value == draft + " edited" @pytest.mark.asyncio async def test_slash_command_with_surrounding_whitespace_still_runs_as_command(): session = _FakeSession() app = DeerFlowTUI(session, LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app.post_message(events.Paste(" /help\n")) await pilot.pause() await pilot.press("enter") await pilot.pause() assert session.client.stream_calls == [] assert app._history.entries() == ["/help"] _LONG_PROMPT = " ".join(f"word{i}" for i in range(60)) # one logical line that soft-wraps @pytest.mark.asyncio @pytest.mark.parametrize("direction", ["up", "down"]) async def test_equal_history_recall_preserves_cursor_and_undo(direction): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test() as pilot: await pilot.pause() app._history.add("same prompt") if direction == "down": # Down will restore the same draft that Up saved. app._history.up("same prompt") app.post_message(events.Paste("same prompt")) await pilot.pause() composer = app.query_one("#composer") composer.move_cursor((0, 2)) await pilot.press(direction) await pilot.pause() assert composer.cursor_location == (0, 2) await pilot.press("ctrl+z") await pilot.pause() assert composer.value == "" @pytest.mark.asyncio async def test_up_inside_a_soft_wrapped_line_moves_the_cursor_not_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(60, 24)) as pilot: await pilot.pause() app._history.add("previous prompt") composer = app.query_one("#composer") composer.value = _LONG_PROMPT composer.cursor_position = len(_LONG_PROMPT) await pilot.pause() assert len(composer.wrapped_document.get_offsets(0)) >= 1 # really wrapped await pilot.press("up") await pilot.pause() assert composer.value == _LONG_PROMPT assert composer.cursor_position < len(_LONG_PROMPT) @pytest.mark.asyncio async def test_down_inside_a_soft_wrapped_line_moves_the_cursor_not_history(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) async with app.run_test(size=(60, 24)) as pilot: await pilot.pause() composer = app.query_one("#composer") composer.value = _LONG_PROMPT composer.cursor_position = 0 await pilot.pause() assert len(composer.wrapped_document.get_offsets(0)) >= 1 await pilot.press("down") await pilot.pause() assert composer.value == _LONG_PROMPT assert composer.cursor_position > 0