diff --git a/README.md b/README.md index 0c4f3726ca..1cb2247ce6 100644 --- a/README.md +++ b/README.md @@ -2487,6 +2487,11 @@ At the last composer row, `Down` leaves an unsent draft untouched unless you are browsing input history; after recalling history, it moves forward to restore your saved draft. +At the first composer row, `Up` also leaves the draft, cursor, and undo history +untouched when no input history is available. +Recalling identical history text or a saved draft also preserves the cursor and +undo history. + See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide. ## Documentation diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index d0f441e0b0..9565b9b671 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -89,6 +89,11 @@ At the last input row, `↓` preserves the current draft unless you are browsing history. After `↑` recalls history, `↓` moves forward and restores the saved draft when it passes the newest entry. +At the first input row, `↑` leaves the draft, cursor, and undo history untouched +when no input history is available. +Recalling an entry or saved draft identical to the current input also preserves +the cursor and undo history. + Transcript updates follow new output while the view is at the bottom. After you scroll upward, streaming refreshes preserve the reading position until you return to the bottom with `PageDown`. diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index 7b357fefc8..0ee1f17a8e 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -13,8 +13,12 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol **Web UI visibility**: the Web UI lists threads from the `threads_meta` SQL table (user-scoped), not the checkpointer. `persistence.py` writes a `threads_meta` row under the default user (`"default"`) into the same DB the Gateway reads — via the harness-only `deerflow.persistence.engine.init_engine_from_config()` — so TUI sessions appear in the Web UI sidebar **without** running the Gateway. Best-effort: a no-op on the `memory` backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop). -`InputHistory.down()` returns `None` when history navigation is inactive; the -app must then leave the composer untouched, including its cursor and undo state. +`InputHistory.up()` returns `None` when history is empty, and `down()` returns +`None` when history navigation is inactive; the app must then leave the composer +untouched, including its cursor and undo state. An empty string is a valid saved draft and must still be restored after history. +History navigation may advance its index without changing the input text. When +the recalled value equals the composer value, skip loading the document so its +cursor and undo state survive. **Tests**: `tests/test_tui_*.py` — pure layers via plain pytest, the app/palette/overlays via Textual's pilot harness with a fake in-process session, and `test_tui_persistence.py` for the `threads_meta` round-trip. diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index ff04568731..10e39f446e 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -317,7 +317,9 @@ class DeerFlowTUI(App): if self._palette_open: self.action_palette_up() else: - self._history_move(self._history.up(self.query_one("#composer", ComposerInput).value)) + value = self._history.up(self.query_one("#composer", ComposerInput).value) + if value is not None: + self._history_move(value) def action_nav_down(self) -> None: if self._palette_open: @@ -329,6 +331,8 @@ class DeerFlowTUI(App): def _history_move(self, value: str) -> None: composer = self.query_one("#composer", ComposerInput) + if composer.value == value: + return composer.value = value composer.cursor_position = len(value) diff --git a/backend/packages/harness/deerflow/tui/input_history.py b/backend/packages/harness/deerflow/tui/input_history.py index 74f58472a5..41eb97c292 100644 --- a/backend/packages/harness/deerflow/tui/input_history.py +++ b/backend/packages/harness/deerflow/tui/input_history.py @@ -32,10 +32,10 @@ class InputHistory: if len(self._entries) > self._limit: self._entries = self._entries[-self._limit :] - def up(self, draft: str = "") -> str: - """Move one entry older. Returns the entry (or ``draft`` if empty).""" + def up(self, draft: str = "") -> str | None: + """Move one entry older; return None when history is empty.""" if not self._entries: - return draft + return None if self._cursor is None: self._draft = draft self._cursor = len(self._entries) - 1 diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index 897655e65c..58e83e173f 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -127,6 +127,38 @@ async def test_multiline_arrows_move_lines_before_input_history(): 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")) @@ -702,6 +734,30 @@ async def test_slash_command_with_surrounding_whitespace_still_runs_as_command() _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")) diff --git a/backend/tests/test_tui_input_history.py b/backend/tests/test_tui_input_history.py index e00dd35e3b..2cce1b0522 100644 --- a/backend/tests/test_tui_input_history.py +++ b/backend/tests/test_tui_input_history.py @@ -64,9 +64,10 @@ def test_down_restores_draft_once_and_then_stops(draft): assert h.down() is None -def test_up_with_empty_history_returns_draft(): +@pytest.mark.parametrize("draft", ["", "keep", "first line\nsecond line"]) +def test_up_with_empty_history_returns_no_change(draft): h = InputHistory() - assert h.up(draft="keep") == "keep" + assert h.up(draft=draft) is None def test_add_resets_navigation_cursor():