deer-flow/backend/tests/test_tui_composer.py
TAI-YUAN GUO a873b78950
fix(tui): preserve multiline pasted input (carries #4630 forward) (#6168)
* fix(tui): preserve multiline pasted input

The TUI composer used a single-line `Input`, so a bracketed paste
containing code, a stack trace, or multiple paragraphs kept only the
first line when submitted. The composer is now a multiline `TextArea`:
Enter still submits, Up/Down move inside the document before falling
back to input history at the first/last line, and the slash palette is
unchanged.

Originally #4630 by @felix-windsor, cherry-picked onto current main;
conflicts in app.py key gating (transcript paging keys), test imports,
and the relocated TUI docs were resolved by hand.

Fixes #4613

* fix(tui): submit multiline input unchanged and document the widgets layer

Address the review on #4630:

- Send non-command input to the agent and history exactly as typed or
  pasted. Only the empty-input check uses strip(), so leading
  indentation and trailing newlines in pasted code and stack traces
  survive. Slash commands are still trimmed so they resolve as before.
- widgets/composer.py imports Textual, so the module-layout notes in
  tui/AGENTS.md and docs/TUI.md now exempt widgets/ alongside app.py.
- Cover pressing Down on the last document line (history fallback),
  whitespace-only input, and slash commands with surrounding whitespace.

Co-authored-by: Felix <felixwindsor3344@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tui): treat soft-wrapped rows as composer navigation boundaries

TextArea soft-wraps long lines, and its Up/Down move between the wrapped
rows of one logical line. The history fallback only looked at logical
rows, so Up from the second visual row of a long prompt recalled history
instead of moving the cursor, and Down from its first visual row could
replace the draft with history's empty value.

Use the TextArea navigator's first/last wrapped-row checks for the
boundaries, and cover wrapped prompts in both directions. Also restore a
tui/AGENTS.md sentence about PageUp/PageDown to the app.py entry it
belongs to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Felix <felixwindsor3344@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 08:29:29 +08:00

63 lines
2.0 KiB
Python

"""Tests for multiline paste and CJK-aware composer cursor offsets."""
import pytest
from textual import events
from textual.app import App, ComposeResult
from textual.geometry import Offset
from deerflow.tui.widgets.composer import ComposerInput
class _Harness(App):
def compose(self) -> ComposeResult:
yield ComposerInput(id="c")
@pytest.mark.asyncio
async def test_cursor_offset_after_cjk_has_no_off_by_one():
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
comp = app.query_one("#c", ComposerInput)
comp.value = "总结一下" # 4 wide chars = 8 cells
comp.cursor_position = len(comp.value)
# Stock Input would report 9 here (unconditional +1); the fix reports 8,
# so the hardware/IME cursor sits exactly after the last character.
assert comp._cursor_offset == Offset(8, 0)
@pytest.mark.asyncio
async def test_cursor_offset_mid_text_is_unchanged():
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
comp = app.query_one("#c", ComposerInput)
comp.value = "总结一下"
comp.cursor_position = 3 # not at end -> 3 wide chars = 6 cells
assert comp._cursor_offset == Offset(6, 0)
@pytest.mark.asyncio
async def test_cursor_offset_ascii_end_is_exact():
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
comp = app.query_one("#c", ComposerInput)
comp.value = "abcd"
comp.cursor_position = 4
assert comp._cursor_offset == Offset(4, 0)
@pytest.mark.asyncio
async def test_paste_preserves_newlines_and_indentation():
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause()
comp = app.query_one("#c", ComposerInput)
comp.focus()
app.post_message(events.Paste("if ready:\n run()\nelse:\n wait()"))
await pilot.pause()
assert comp.value == "if ready:\n run()\nelse:\n wait()"
assert comp.cursor_location == (3, 10)