fix(tui): preserve drafts when Down is not navigating history (#6219)

This commit is contained in:
Daoyuan Li 2026-10-03 01:43:17 -07:00 committed by GitHub
parent 7b8a7925c2
commit 9b16792168
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 4 deletions

View File

@ -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

View File

@ -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`.

View File

@ -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.

View File

@ -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)

View File

@ -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]

View File

@ -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()

View File

@ -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"