From 61c153ff0993c7075aeb8bb6943fd358f0ce7bc5 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Wed, 5 Aug 2026 08:15:28 +0800 Subject: [PATCH] feat(tui): add transparent terminal background (#4631) Co-authored-by: Willem Jiang --- README.md | 1 + backend/AGENTS.md | 2 +- backend/docs/TUI.md | 9 ++++ backend/packages/harness/deerflow/tui/app.py | 19 +++++++- backend/packages/harness/deerflow/tui/cli.py | 9 ++++ backend/tests/test_tui_cli.py | 13 ++++++ backend/tests/test_tui_transparent.py | 49 ++++++++++++++++++++ 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_tui_transparent.py diff --git a/README.md b/README.md index b558ae5c0..0539310d5 100644 --- a/README.md +++ b/README.md @@ -1157,6 +1157,7 @@ Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigge uv pip install 'deerflow-harness[tui]' # optional 'textual' dependency deerflow # launch the terminal UI (TTY required) +deerflow --tui-transparent # use the terminal's default background deerflow --continue # resume the most recent thread deerflow --resume THREAD # resume a thread by id deerflow --print "summarize this repo" # headless one-shot answer to stdout diff --git a/backend/AGENTS.md b/backend/AGENTS.md index cba186e19..badc625e5 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1233,7 +1233,7 @@ PYTHONPATH=. uv run python scripts/benchmark/checkpoint/summarize_production.py 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): -- `cli.py` — `plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. TTY → TUI, else headless help. 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). +- `cli.py` — `plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. 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). diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index 693bf83d9..d6b779555 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -21,6 +21,7 @@ Launch modes: |---|---| | `deerflow` | Launch the TUI when stdin/stdout are TTYs | | `deerflow --tui` | Force the TUI (clear diagnostic if `textual` is missing) | +| `deerflow --tui-transparent` | Use the terminal's default background when launching the TUI | | `deerflow --cli` | Force headless/classic mode for one invocation | | `deerflow chat` | Same TUI conversation surface | | `deerflow --continue` | Resume the most recent thread | @@ -30,10 +31,18 @@ Launch modes: | `deerflow --recursion-limit 250 --print "question"` | Set the headless agent-loop super-step limit | | `echo "q" \| deerflow --print` | Read the message from stdin | | `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment | +| `DEER_FLOW_TUI_TRANSPARENT=1 deerflow` | Persist terminal-background rendering via environment | If no TTY is available and no headless flag is given, `deerflow` prints guidance instead of hanging. +Transparent rendering is opt-in; the solid DeerFlow palette remains the default. +The transparent mode uses Textual's `ansi_default` background for the main +screen, header, transcript, status, palette, composer, and modal surfaces while +keeping truecolor foregrounds and selection highlights. Combine +`--tui-transparent` with `--tui` when the UI also needs to be forced without a +detected TTY. + Headless runs use a recursion limit of `100` by default. Pass a positive `--recursion-limit` when a longer agent loop is expected. This is a LangGraph super-step budget, so it can include model and tool-execution steps rather than diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 99efaf880..1d64d909c 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -41,6 +41,20 @@ _HELP_KEYS = "Keys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · _HELP_TEXT = f"{format_command_help()}\n{_HELP_KEYS}" +_TRANSPARENT_CSS = """ +Screen, +#header, +#scroll, +#status, +#palette, +#composer, +SelectScreen #dialog, +SelectScreen OptionList { + background: ansi_default; +} +""" + + class SelectScreen(ModalScreen): """A centered modal that returns the id of the chosen option (or None).""" @@ -158,7 +172,10 @@ class DeerFlowTUI(App): ] def __init__(self, session, plan) -> None: - super().__init__() + transparent = bool(getattr(plan, "transparent", False)) + if transparent: + self.CSS = f"{self.CSS}\n{_TRANSPARENT_CSS}" + super().__init__(ansi_color=True if transparent else None) self.session = session self.plan = plan self.state = initial_state() diff --git a/backend/packages/harness/deerflow/tui/cli.py b/backend/packages/harness/deerflow/tui/cli.py index 60a4cd014..31e7e826b 100644 --- a/backend/packages/harness/deerflow/tui/cli.py +++ b/backend/packages/harness/deerflow/tui/cli.py @@ -30,6 +30,7 @@ class LaunchPlan: thread_id: str | None = None continue_recent: bool = False forced_tui: bool = False + transparent: bool = False recursion_limit: int | None = None reason: str = "" @@ -70,6 +71,11 @@ def build_parser() -> argparse.ArgumentParser: help="headless streaming: emit newline-delimited JSON StreamEvents and exit", ) parser.add_argument("--tui", action="store_true", help="force the terminal UI (error if unavailable)") + parser.add_argument( + "--tui-transparent", + action="store_true", + help="use the terminal's default background in the TUI", + ) parser.add_argument("--cli", action="store_true", help="force headless/classic mode for one invocation") parser.add_argument("--continue", dest="continue_recent", action="store_true", help="resume the most recent thread") parser.add_argument("--resume", dest="resume", metavar="THREAD", default=None, help="resume a thread by id or title") @@ -162,6 +168,7 @@ def plan_launch( ) forced_tui = bool(args.tui) + transparent = bool(args.tui_transparent) or _truthy(env.get("DEER_FLOW_TUI_TRANSPARENT")) if forced_tui or _truthy(env.get("DEER_FLOW_TUI")) or (stdin_isatty and stdout_isatty): return LaunchPlan( mode="tui", @@ -169,6 +176,7 @@ def plan_launch( thread_id=resume, continue_recent=continue_recent, forced_tui=forced_tui, + transparent=transparent, ) return LaunchPlan( @@ -189,6 +197,7 @@ deerflow — DeerFlow terminal workbench deerflow launch the terminal UI (TTY required) deerflow --tui force the terminal UI + deerflow --tui-transparent use the terminal's default background deerflow --continue resume the most recent thread in the UI deerflow --resume THREAD resume a thread by id or title deerflow --print "question" one-shot answer to stdout diff --git a/backend/tests/test_tui_cli.py b/backend/tests/test_tui_cli.py index 9939e79e2..6cdacca27 100644 --- a/backend/tests/test_tui_cli.py +++ b/backend/tests/test_tui_cli.py @@ -13,6 +13,7 @@ def test_bare_command_on_tty_launches_tui(): p = plan([]) assert p.mode == "tui" assert p.forced_tui is False + assert p.transparent is False def test_non_tty_with_no_message_falls_back_to_headless_help(): @@ -73,6 +74,18 @@ def test_env_var_forces_tui(): assert p.mode == "tui" +def test_transparent_flag_is_carried_to_tui_plan(): + p = plan(["--tui-transparent"]) + assert p.mode == "tui" + assert p.transparent is True + + +def test_transparent_env_is_carried_to_tui_plan(): + p = plan([], env={"DEER_FLOW_TUI_TRANSPARENT": "yes"}) + assert p.mode == "tui" + assert p.transparent is True + + def test_cli_flag_with_message_runs_print(): p = plan(["--cli", "do", "this", "thing"]) assert p.mode == "print" diff --git a/backend/tests/test_tui_transparent.py b/backend/tests/test_tui_transparent.py new file mode 100644 index 000000000..6a654efbc --- /dev/null +++ b/backend/tests/test_tui_transparent.py @@ -0,0 +1,49 @@ +"""Tests for opt-in terminal-background rendering in the TUI.""" + +import pytest + +from deerflow.tui.app import DeerFlowTUI, SelectScreen +from deerflow.tui.cli import LaunchPlan +from deerflow.tui.theme import THEME + + +class _FakeClient: + def list_models(self): + return {"models": []} + + def list_skills(self, enabled_only=False): + return {"skills": []} + + +class _FakeSession: + client = _FakeClient() + + def resolve_thread(self, plan): + return None + + +@pytest.mark.asyncio +async def test_transparent_tui_uses_terminal_default_for_background_surfaces(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui", transparent=True)) + async with app.run_test() as pilot: + await pilot.pause() + + assert app.ansi_color is True + assert app.screen.styles.background.hex == "ansi_default" + for selector in ("#header", "#scroll", "#status", "#palette", "#composer"): + assert app.screen.query_one(selector).styles.background.hex == "ansi_default" + + app.push_screen(SelectScreen("Pick one", [("one", "One")])) + await pilot.pause() + assert app.screen.styles.background.hex == "ansi_default" + assert app.screen.query_one("#dialog").styles.background.hex == "ansi_default" + assert app.screen.query_one("OptionList").styles.background.hex == "ansi_default" + + +@pytest.mark.asyncio +async def test_default_tui_keeps_solid_theme_backgrounds(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + assert app.screen.styles.background.hex.lower() == THEME.bg + assert app.screen.query_one("#header").styles.background.hex.lower() == THEME.panel