diff --git a/README.md b/README.md index a8ac76ea73..0274a17752 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index 8e6c5edf58..d758328576 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -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) diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index 00f53fa5f3..4b4d46360d 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -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). diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 47f480a111..618f1abb05 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -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) diff --git a/backend/packages/harness/deerflow/tui/widgets/composer.py b/backend/packages/harness/deerflow/tui/widgets/composer.py index 48049ae51b..73683e5df4 100644 --- a/backend/packages/harness/deerflow/tui/widgets/composer.py +++ b/backend/packages/harness/deerflow/tui/widgets/composer.py @@ -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)) diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index f34bbdc9e7..39e0b9aa2b 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -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 diff --git a/backend/tests/test_tui_composer.py b/backend/tests/test_tui_composer.py index 84cca12e05..87967be1c9 100644 --- a/backend/tests/test_tui_composer.py +++ b/backend/tests/test_tui_composer.py @@ -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)