From 9b167921686a7e83087ef91a13a2fc66bd805b34 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:43:17 -0700 Subject: [PATCH] fix(tui): preserve drafts when Down is not navigating history (#6219) --- README.md | 4 ++ backend/docs/TUI.md | 4 ++ .../packages/harness/deerflow/tui/AGENTS.md | 4 ++ backend/packages/harness/deerflow/tui/app.py | 4 +- .../harness/deerflow/tui/input_history.py | 6 +- backend/tests/test_tui_app.py | 59 +++++++++++++++++++ backend/tests/test_tui_input_history.py | 16 +++++ 7 files changed, 93 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7c6ff73e78..9a35715d29 100644 --- a/README.md +++ b/README.md @@ -2391,6 +2391,10 @@ Headless `--print` and `--json` exit with status `1` when the run fails, includi A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, display-only `/clear`, `/goal` goal management, `/model` and `/threads` pickers, input history, PageUp/PageDown transcript navigation, and `Esc` / `Ctrl+C` interrupt. The composer preserves line breaks and indentation in pasted code, stack traces, and multi-paragraph prompts; `Enter` sends the complete document. Transcript refreshes preserve your reading position after you scroll upward and resume following new output when you return to the bottom. `/clear` removes rows from the current terminal display without deleting the thread or its persisted conversation; `/new` and `/clear` ask you to wait during an active run instead of resetting in-flight display state. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**. +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. + 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 d758328576..d0f441e0b0 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -85,6 +85,10 @@ for trusted embedded CLI runs. | `Ctrl+C` | Interrupt the active run, or quit when idle | | `Ctrl+L` | Redraw · `Ctrl+U` clear composer | +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. + 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 4b4d46360d..7b357fefc8 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -13,4 +13,8 @@ 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. +An empty string is a valid saved draft and must still be restored after history. + **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 618f1abb05..ff04568731 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -323,7 +323,9 @@ class DeerFlowTUI(App): if self._palette_open: self.action_palette_down() else: - self._history_move(self._history.down()) + value = self._history.down() + if value is not None: + self._history_move(value) def _history_move(self, value: str) -> None: composer = self.query_one("#composer", ComposerInput) diff --git a/backend/packages/harness/deerflow/tui/input_history.py b/backend/packages/harness/deerflow/tui/input_history.py index ed88f01ae9..74f58472a5 100644 --- a/backend/packages/harness/deerflow/tui/input_history.py +++ b/backend/packages/harness/deerflow/tui/input_history.py @@ -43,10 +43,10 @@ class InputHistory: self._cursor -= 1 return self._entries[self._cursor] - def down(self) -> str: - """Move one entry newer. Past the newest entry, restores the draft.""" + def down(self) -> str | None: + """Move newer or restore the draft; return None when not navigating.""" if self._cursor is None: - return self._draft + return None if self._cursor < len(self._entries) - 1: self._cursor += 1 return self._entries[self._cursor] diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index 39e0b9aa2b..897655e65c 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -624,6 +624,65 @@ async def test_down_on_last_line_of_multiline_input_falls_back_to_history(): 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() diff --git a/backend/tests/test_tui_input_history.py b/backend/tests/test_tui_input_history.py index 34062d7a6f..e00dd35e3b 100644 --- a/backend/tests/test_tui_input_history.py +++ b/backend/tests/test_tui_input_history.py @@ -1,5 +1,7 @@ """Tests for bounded composer input history (pure).""" +import pytest + from deerflow.tui.input_history import InputHistory @@ -48,6 +50,20 @@ def test_down_walks_forward_then_restores_draft(): assert h.down() == "my draft" # past newest -> stashed draft +@pytest.mark.parametrize("entries", [[], ["previous prompt"]]) +def test_down_without_history_navigation_returns_no_change(entries): + h = InputHistory(entries) + assert h.down() is None + + +@pytest.mark.parametrize("draft", ["", "first line\nsecond line"]) +def test_down_restores_draft_once_and_then_stops(draft): + h = InputHistory(["previous prompt"]) + assert h.up(draft) == "previous prompt" + assert h.down() == draft + assert h.down() is None + + def test_up_with_empty_history_returns_draft(): h = InputHistory() assert h.up(draft="keep") == "keep"