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>
This commit is contained in:
TAI-YUAN GUO 2026-10-03 08:29:29 +08:00 committed by GitHub
parent f11be0a7bc
commit a873b78950
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 278 additions and 43 deletions

View File

@ -2332,7 +2332,7 @@ deerflow --recursion-limit 250 --print "task" # override the headless agent-loop
Headless `--print` and `--json` exit with status `1` when the run fails, including provider errors returned as fallback messages. `--print` still writes the fallback text to stdout; `--json` appends a terminal error record.
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. 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**.
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**.
See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide.

View File

@ -68,7 +68,9 @@ for trusted embedded CLI runs.
smooth on long threads.
- **Status line** — run state + animated spinner, model, thread title, token
usage, and an `esc interrupt` hint while a run is active.
- **Composer** — rounded input box. `/` opens the command palette.
- **Composer** — rounded multiline input box. `/` opens the command palette.
Pasted code, stack traces, and multi-paragraph prompts retain their line
breaks and indentation; `Enter` sends the complete document.
### Keys
@ -76,7 +78,7 @@ for trusted embedded CLI runs.
|---|---|
| `Enter` | Send message / accept palette selection |
| `/` | Open the slash-command palette |
| `↑` / `↓` | Palette navigation, or input history when the palette is closed |
| `↑` / `↓` | Palette navigation; otherwise move inside multiline input, then use history at the first/last row (wrapped rows included) |
| `PageUp` / `PageDown` | Scroll the transcript without moving focus from the composer |
| `Tab` | Complete the highlighted command (adds a trailing space) |
| `Esc` | Close the palette / overlay |
@ -115,13 +117,14 @@ render.py Rich renderers for header / transcript / status / palette (pure)
theme.py palette + symbols
app.py Textual App: composes widgets, drives runs on a worker thread,
marshals actions back to the UI thread, renders ViewState
widgets/ Textual widgets (the multiline composer)
persistence.py writes threads_meta so sessions appear in the Web UI (below)
```
`DeerFlowClient.stream()` is a **synchronous** generator, so the app runs it on a
Textual worker *thread* and marshals each yielded action back to the UI thread
via `call_from_thread`. The pure layers (everything except `app.py`) have no
Textual dependency and are unit-tested directly with synthetic `StreamEvent`s.
via `call_from_thread`. The pure layers (everything except `app.py` and `widgets/`)
have no Textual dependency and are unit-tested directly with synthetic `StreamEvent`s.
## Web UI visibility (shared persistence)

View File

@ -2,12 +2,13 @@
A terminal-native UI over the embedded harness, exposed as the `deerflow` console script (`[project.scripts]` in `packages/harness/pyproject.toml`). It is a UI shell over `DeerFlowClient` and does **not** fork agent behavior. `textual` is an optional dependency (`deerflow-harness[tui]`; also in the backend dev group); the console script degrades to headless help when it is absent. Full guide: [docs/TUI.md](../../../../docs/TUI.md).
**Module layout** (all layers except `app.py` are pure / Textual-free and unit-tested directly):
**Module layout** (all layers except `app.py` and `widgets/` are pure / Textual-free and unit-tested directly):
- `cli.py` — `plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. Headless modes exit `1` when the final AI message carries `deerflow_error_fallback` (provider failures arrive as flagged messages, not exceptions). `_RunOutcome` and `chat()` share `_AIMessageAccumulator`: deltas accumulate per message id and the last content-bearing AI id supplies the text; metadata-only events preserve that id. Fallback writers must supply text or flag an id whose text was already emitted. Keep the client import lazy so help and launch planning do not load the agent graph. TTY → TUI, else headless help. `--tui-transparent` / `DEER_FLOW_TUI_TRANSPARENT` opt into terminal-default backgrounds without changing the solid-theme default. Uses an **absolute** `from deerflow.tui.app import run_tui` so the `app.py` module name doesn't trip `test_harness_boundary.py` (which records relative import module names verbatim).
- `view_state.py` — `ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
- `runtime.py` — `translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude every shared `RESERVED_SLASH_SKILL_NAMES` entry that the agent runtime rejects from both its picker and resolver; the `context` skill is the exception for ordinary task text, while the exact composer-only `/context compact` alias remains unavailable as a skill activation.
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; routes idle display-only `/clear` through `ClearRows` without replacing the active thread, and blocks state-resetting local commands like `/new` and `/clear` with the standard "Still working" message during an active run; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer. Application-level PageUp/PageDown bindings scroll the transcript while preserving composer focus; streaming follows output only while the transcript remains at the bottom.
- `widgets/composer.py` — the composer, a Textual `TextArea`: bracketed multiline pastes retain their line breaks and indentation, `Enter` submits the whole document unchanged (only an all-whitespace document counts as empty), and Up/Down move inside it before falling back to input history at the first/last visual row (soft-wrapped rows count, so a long wrapped prompt is navigable).
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.
**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).

View File

@ -15,7 +15,7 @@ from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Input, Label, OptionList, Static
from textual.widgets import Label, OptionList, Static, TextArea
from textual.widgets.option_list import Option
from deerflow.runtime.goal import parse_goal_command
@ -128,6 +128,9 @@ class DeerFlowTUI(App):
#composer:focus {{
border: round {THEME.primary};
}}
#composer.multiline {{
height: 5;
}}
SelectScreen {{
align: center middle;
}}
@ -205,14 +208,19 @@ class DeerFlowTUI(App):
yield Static(id="transcript")
yield Static(id="status")
yield Static(id="palette")
yield ComposerInput(placeholder="Message DeerFlow… ( / for commands )", id="composer")
yield ComposerInput(
placeholder="Message DeerFlow… ( / for commands )",
id="composer",
compact=True,
highlight_cursor_line=False,
)
def on_mount(self) -> None:
self._load_session_info()
self._refresh_all()
self.set_interval(0.1, self._tick_spinner)
self.set_interval(0.06, self._flush_transcript) # coalesce streaming re-renders
self.query_one("#composer", Input).focus()
self.query_one("#composer", ComposerInput).focus()
if self.plan and getattr(self.plan, "message", None):
self._send_to_agent(self.plan.message)
@ -238,18 +246,24 @@ class DeerFlowTUI(App):
# ----- input --------------------------------------------------------- #
def on_input_submitted(self, event: Input.Submitted) -> None:
text = event.value.strip()
def on_composer_input_submitted(self, event: ComposerInput.Submitted) -> None:
# Send pasted documents unchanged (leading indentation, trailing newlines);
# only slash commands are trimmed so they still resolve as commands.
stripped = event.value.strip()
text = stripped if stripped.startswith("/") else event.value
event.input.value = ""
self._close_palette()
if not text:
if not stripped:
return
self._history.add(text)
self._handle_submit(text)
def on_input_changed(self, event: Input.Changed) -> None:
value = event.value
if value.startswith("/") and " " not in value:
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not isinstance(event.text_area, ComposerInput):
return
value = event.text_area.value
event.text_area.set_class("\n" in value, "multiline")
if value.startswith("/") and " " not in value and "\n" not in value:
from .command_registry import build_registry, filter_commands
items = filter_commands(build_registry(self._skills_meta), value[1:])
@ -277,8 +291,15 @@ class DeerFlowTUI(App):
# intercept its keys; let the overlay handle them natively.
if len(self.screen_stack) > 1:
return None
# History navigation, transcript paging, Tab, and Esc are always
# consumed. Enter falls through to the Input when the palette is
# Within a multiline paste, Up/Down belong to TextArea until the
# cursor reaches the first/last document line. At those boundaries
# they fall back to shell-style input history.
if not self._palette_open and action in {"nav_up", "nav_down"}:
composer = self.query_one("#composer", ComposerInput)
if (action == "nav_up" and composer.can_move_up) or (action == "nav_down" and composer.can_move_down):
return None
# History navigation, transcript paging, Tab, and Esc are otherwise
# consumed. Enter falls through to the composer when the palette is
# closed so it submits normally.
if action in {
"nav_up",
@ -296,7 +317,7 @@ class DeerFlowTUI(App):
if self._palette_open:
self.action_palette_up()
else:
self._history_move(self._history.up(self.query_one("#composer", Input).value))
self._history_move(self._history.up(self.query_one("#composer", ComposerInput).value))
def action_nav_down(self) -> None:
if self._palette_open:
@ -305,7 +326,7 @@ class DeerFlowTUI(App):
self._history_move(self._history.down())
def _history_move(self, value: str) -> None:
composer = self.query_one("#composer", Input)
composer = self.query_one("#composer", ComposerInput)
composer.value = value
composer.cursor_position = len(value)
@ -364,14 +385,14 @@ class DeerFlowTUI(App):
self._fill_from_palette()
return
self._close_palette()
self.query_one("#composer", Input).value = ""
self.query_one("#composer", ComposerInput).value = ""
self._handle_submit(f"/{item.name}")
def _fill_from_palette(self) -> None:
item = self._current_palette_item()
if item is None:
return
composer = self.query_one("#composer", Input)
composer = self.query_one("#composer", ComposerInput)
composer.value = f"/{item.name} "
composer.cursor_position = len(composer.value)
self._close_palette()
@ -683,7 +704,7 @@ class DeerFlowTUI(App):
self._refresh_all()
def action_clear_composer(self) -> None:
self.query_one("#composer", Input).value = ""
self.query_one("#composer", ComposerInput).value = ""
def action_transcript_page_up(self) -> None:
scroll = self.query_one("#scroll", VerticalScroll)

View File

@ -1,23 +1,64 @@
"""Composer input with a wide-character (CJK) cursor fix.
Textual's ``Input._cursor_offset`` adds an unconditional ``+1`` when the cursor
is at the end of the value. After double-width (CJK) characters that overshoots
by one cell, which misplaces the *hardware / IME* cursor — the visible drift when
typing Chinese in terminals such as iTerm2. (The on-screen block cursor is drawn
separately in ``render_line`` via character-index styling and is unaffected; this
only corrects the terminal cursor anchor that the IME candidate window follows.)
English input never exercises this because it doesn't go through an IME, which is
why the drift only shows up for CJK.
"""
"""Multiline TUI composer with the existing Enter-to-submit contract."""
from __future__ import annotations
from textual.widgets import Input
from dataclasses import dataclass
from textual.binding import Binding
from textual.message import Message
from textual.widgets import TextArea
class ComposerInput(Input):
class ComposerInput(TextArea):
"""A ``TextArea`` that submits on Enter and exposes input-like helpers.
``TextArea`` preserves bracketed multiline pastes and computes its hardware
cursor from display-cell widths, including CJK. The small compatibility
surface keeps the surrounding palette/history code independent of Textual's
document locations.
"""
BINDINGS = [Binding("enter", "submit", show=False, priority=True)]
@dataclass
class Submitted(Message):
input: ComposerInput
value: str
@property
def control(self) -> ComposerInput:
return self.input
@property
def _cursor_offset(self) -> int:
# True cell offset of the cursor, without Textual's end-of-value +1.
return self._position_to_cell(self.cursor_position)
def value(self) -> str:
return self.text
@value.setter
def value(self, value: str) -> None:
self.load_text(value)
@property
def cursor_position(self) -> int:
row, column = self.cursor_location
return sum(len(line) + 1 for line in self.document.lines[:row]) + column
@cursor_position.setter
def cursor_position(self, position: int) -> None:
position = max(0, min(position, len(self.text)))
prefix = self.text[:position]
row = prefix.count("\n")
column = len(prefix.rsplit("\n", 1)[-1])
self.move_cursor((row, column))
# Boundaries are visual rows: TextArea soft-wraps long lines, and its Up/Down
# move between wrapped rows of one logical line before leaving it.
@property
def can_move_up(self) -> bool:
return not self.navigator.is_first_wrapped_line(self.cursor_location)
@property
def can_move_down(self) -> bool:
return not self.navigator.is_last_wrapped_line(self.cursor_location)
def action_submit(self) -> None:
self.post_message(self.Submitted(self, self.text))

View File

@ -8,6 +8,7 @@ import asyncio
import threading
import pytest
from textual import events
from textual.containers import VerticalScroll
from deerflow.client import StreamEvent
@ -82,6 +83,50 @@ async def test_app_runs_a_turn_and_renders_streamed_assistant():
assert app.state.usage == {"total_tokens": 3}
@pytest.mark.asyncio
async def test_multiline_paste_reaches_agent_without_dropping_lines():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = 'Traceback:\n File "worker.py", line 7\nRuntimeError: boom'
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert session.client.stream_calls[0][0] == pasted
@pytest.mark.asyncio
async def test_multiline_arrows_move_lines_before_input_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("previous prompt")
app.post_message(events.Paste("first line\nsecond line"))
await pilot.pause()
composer = app.query_one("#composer")
assert composer.cursor_location == (1, 11)
await pilot.press("up")
await pilot.pause()
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 0
await pilot.press("down")
await pilot.pause()
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 1
composer.cursor_position = 0
await pilot.press("up")
await pilot.pause()
assert composer.value == "previous prompt"
@pytest.mark.asyncio
async def test_app_assigns_thread_id_on_first_send():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
@ -525,3 +570,111 @@ async def test_goal_set_failure_shows_error_tone():
await pilot.pause()
errors = [r for r in _system_rows(app) if r.tone == "error"]
assert any("Could not set goal." in r.text for r in errors)
@pytest.mark.asyncio
async def test_multiline_paste_keeps_leading_indentation_and_trailing_newline():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = " def f():\n return 1\n"
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert session.client.stream_calls[0][0] == pasted
assert app._history.entries() == [pasted]
@pytest.mark.asyncio
async def test_whitespace_only_input_is_not_submitted():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(" \n\t\n"))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert session.client.stream_calls == []
assert app._history.entries() == []
@pytest.mark.asyncio
async def test_down_on_last_line_of_multiline_input_falls_back_to_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("first line\nsecond line")
await pilot.press("up")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 1
# Already on the last document line: Down leaves the recalled entry for the draft.
await pilot.press("down")
await pilot.pause()
assert composer.value == ""
@pytest.mark.asyncio
async def test_slash_command_with_surrounding_whitespace_still_runs_as_command():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(" /help\n"))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert session.client.stream_calls == []
assert app._history.entries() == ["/help"]
_LONG_PROMPT = " ".join(f"word{i}" for i in range(60)) # one logical line that soft-wraps
@pytest.mark.asyncio
async def test_up_inside_a_soft_wrapped_line_moves_the_cursor_not_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(60, 24)) as pilot:
await pilot.pause()
app._history.add("previous prompt")
composer = app.query_one("#composer")
composer.value = _LONG_PROMPT
composer.cursor_position = len(_LONG_PROMPT)
await pilot.pause()
assert len(composer.wrapped_document.get_offsets(0)) >= 1 # really wrapped
await pilot.press("up")
await pilot.pause()
assert composer.value == _LONG_PROMPT
assert composer.cursor_position < len(_LONG_PROMPT)
@pytest.mark.asyncio
async def test_down_inside_a_soft_wrapped_line_moves_the_cursor_not_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(60, 24)) as pilot:
await pilot.pause()
composer = app.query_one("#composer")
composer.value = _LONG_PROMPT
composer.cursor_position = 0
await pilot.pause()
assert len(composer.wrapped_document.get_offsets(0)) >= 1
await pilot.press("down")
await pilot.pause()
assert composer.value == _LONG_PROMPT
assert composer.cursor_position > 0

View File

@ -1,7 +1,9 @@
"""Tests for the CJK-aware composer cursor offset."""
"""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
@ -21,7 +23,7 @@ async def test_cursor_offset_after_cjk_has_no_off_by_one():
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 == 8
assert comp._cursor_offset == Offset(8, 0)
@pytest.mark.asyncio
@ -32,7 +34,7 @@ async def test_cursor_offset_mid_text_is_unchanged():
comp = app.query_one("#c", ComposerInput)
comp.value = "总结一下"
comp.cursor_position = 3 # not at end -> 3 wide chars = 6 cells
assert comp._cursor_offset == 6
assert comp._cursor_offset == Offset(6, 0)
@pytest.mark.asyncio
@ -43,4 +45,18 @@ async def test_cursor_offset_ascii_end_is_exact():
comp = app.query_one("#c", ComposerInput)
comp.value = "abcd"
comp.cursor_position = 4
assert comp._cursor_offset == 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)