mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
4b0c512d65
commit
5c3329afad
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user