fix(tui): preserve cursor and undo on unchanged history recalls (#6248)

* fix(tui): preserve composer on Up with empty history

* fix(tui): preserve cursor and undo on identical history recalls
This commit is contained in:
Daoyuan Li 2026-10-04 02:04:58 -07:00 committed by GitHub
parent 4b0c512d65
commit 5c3329afad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 83 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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