feat(tui): add transparent terminal background (#4631)

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Felix Wang 2026-08-05 08:15:28 +08:00 committed by GitHub
parent 2e9ee329ce
commit 61c153ff09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 100 additions and 2 deletions

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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