mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 13:29:15 +00:00
feat(tui): add transparent terminal background (#4631)
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
2e9ee329ce
commit
61c153ff09
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
49
backend/tests/test_tui_transparent.py
Normal file
49
backend/tests/test_tui_transparent.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user