diff --git a/CHANGELOG.md b/CHANGELOG.md index add7931a14..cc063f206c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1402,6 +1402,11 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Fixed +- **tui:** The `/threads` and `/model` pickers show thread titles and model + names exactly as written. Labels were parsed as Textual markup, so a title + such as `Fix [/bold] in parser` exited the TUI with `MarkupError` whenever the + picker opened, and `[a]` vanished from a title. ([#7631]) + - **channels:** DingTalk AI cards no longer fetch an access token on the Gateway event loop. Card creation and card streaming went through dingtalk-stream's synchronous `get_access_token()`, which posts with @@ -13231,3 +13236,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7594]: https://github.com/bytedance/deer-flow/pull/7594 [#7597]: https://github.com/bytedance/deer-flow/pull/7597 [#7606]: https://github.com/bytedance/deer-flow/pull/7606 +[#7631]: https://github.com/bytedance/deer-flow/pull/7631 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 200daaa7ee..54e829b302 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1116,6 +1116,10 @@ ### 修复 +- **TUI:** `/threads` 和 `/model` 选择器现在按原样显示线程标题和模型名称。此前标签 + 会被当作 Textual 标记解析,标题如 `Fix [/bold] in parser` 会在打开选择器时以 + `MarkupError` 使 TUI 退出,标题中的 `[a]` 也会消失。([#7631]) + - **渠道:** 钉钉 AI 卡片不再在 Gateway 事件循环上获取 access token。此前创建卡片和流式更新卡片 都会调用 dingtalk-stream 的同步 `get_access_token()`,它用 `requests` 发请求且没有超时,因此 首张卡片以及之后每次 SDK token 刷新(约每两小时一次)都会在整个请求期间阻塞所有渠道、请求和 @@ -10550,3 +10554,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7594]: https://github.com/bytedance/deer-flow/pull/7594 [#7597]: https://github.com/bytedance/deer-flow/pull/7597 [#7606]: https://github.com/bytedance/deer-flow/pull/7606 +[#7631]: https://github.com/bytedance/deer-flow/pull/7631 diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index a572263fe4..2085f00b7f 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -109,7 +109,8 @@ return to the bottom with `PageDown`. `/help` `/new` `/clear` `/goal` `/resume` `/threads` (`/switch`) `/model` `/skills` `/tools` `/mcp` `/memory` `/uploads` `/usage` `/config` `/quit`, plus `/ task` to activate any enabled skill for the current turn (same -semantics as elsewhere in DeerFlow). `/model` and `/threads` open modal pickers. +semantics as elsewhere in DeerFlow). `/model` and `/threads` open modal pickers, which +show thread titles and model names exactly as written, square brackets included. `/clear` removes the current transcript rows from the terminal display only; it keeps the active thread and persisted conversation intact. During an active run, `/new` and `/clear` ask you to wait for the run to finish instead of resetting in-flight display state. diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index 3c8f44d5ba..82c7073b12 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -7,7 +7,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol - `view_state.py` — `ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events. Assistant deltas append verbatim: the client sends only incremental text per id, so a delta that repeats or extends the row (`ha` + `ha`, a closing `**`) is new text, not a re-send. - `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. +- `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. `SelectScreen` wraps its title and option labels in Rich `Text`: Textual parses a plain `str` as content markup, so a thread title like `Fix [/bold] in parser` crashed the app and `[a]` vanished. - `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`. The thread picker and `--resume ` read `DeerFlowClient.list_threads()`, where `limit` counts threads (newest created first); `--continue` passes `sort_by="updated_at"` to resume the most recently active thread. diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 394e594f5a..2483b56247 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -12,6 +12,7 @@ import uuid from dataclasses import dataclass, field from threading import Event, Thread +from rich.text import Text from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Vertical, VerticalScroll @@ -76,7 +77,13 @@ SelectScreen OptionList { class SelectScreen(ModalScreen): - """A centered modal that returns the id of the chosen option (or None).""" + """A centered modal that returns the id of the chosen option (or None). + + Labels are shown as written. Textual parses a plain ``str`` as content + markup, so a thread title like ``Fix [/bold] in parser`` would raise + ``MarkupError`` and ``[a]`` would vanish; wrapping in ``Text`` keeps them + literal. + """ BINDINGS = [Binding("escape", "cancel", "Close")] @@ -87,8 +94,8 @@ class SelectScreen(ModalScreen): def compose(self) -> ComposeResult: with Vertical(id="dialog"): - yield Label(self._title, id="dialog-title") - yield OptionList(*[Option(label, id=oid) for oid, label in self._options], id="dialog-list") + yield Label(Text(self._title), id="dialog-title") + yield OptionList(*[Option(Text(label), id=oid) for oid, label in self._options], id="dialog-list") def on_mount(self) -> None: self.query_one(OptionList).focus() diff --git a/backend/tests/test_tui_overlays.py b/backend/tests/test_tui_overlays.py index 1c2682c0db..19a4d9a489 100644 --- a/backend/tests/test_tui_overlays.py +++ b/backend/tests/test_tui_overlays.py @@ -125,3 +125,70 @@ async def test_picker_escape_cancels_without_change(): await pilot.press("escape") await _settle(pilot, lambda: not isinstance(app.screen, SelectScreen)) assert app._model_override is None + + +# Square brackets are Textual content markup when a widget receives a plain +# str. Thread titles come from the user's first message and model names from +# config, so the pickers must show them as written. +_BRACKETED_THREADS = [ + {"thread_id": "thread-cccccccc", "title": "Fix [/bold] in parser"}, + {"thread_id": "thread-dddddddd", "title": "Index [a] then [b]"}, +] +_BRACKETED_MODELS = [{"name": "beta", "display_name": "Qwen [beta]"}, {"name": "italic", "display_name": "x [/i] y"}] + + +class _BracketedClient(_FakeClient): + def list_models(self): + return {"models": _BRACKETED_MODELS} + + def list_threads(self, limit=10): + return {"thread_list": _BRACKETED_THREADS} + + +class _BracketedSession(_FakeSession): + def __init__(self): + self.client = _BracketedClient() + + +def _picker_lines(app) -> list[str]: + options = app.screen.query_one("#dialog-list") + return [strip.text.strip() for strip in options.render_lines(options.region.reset_offset)[: options.option_count]] + + +@pytest.mark.asyncio +async def test_thread_switcher_shows_bracketed_titles_as_written(): + app = DeerFlowTUI(_BracketedSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#composer").value = "/threads" + await pilot.press("enter") + assert await _settle(pilot, lambda: isinstance(app.screen, SelectScreen)) + assert _picker_lines(app) == ["Fix [/bold] in parser · thread-c", "Index [a] then [b] · thread-d"] + await pilot.press("down", "enter") + await _settle(pilot, lambda: app._conv_thread_id == "thread-dddddddd") + assert app._conv_thread_id == "thread-dddddddd" + + +@pytest.mark.asyncio +async def test_model_picker_shows_bracketed_names_as_written(): + app = DeerFlowTUI(_BracketedSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#composer").value = "/model" + await pilot.press("enter") + assert await _settle(pilot, lambda: isinstance(app.screen, SelectScreen)) + assert _picker_lines(app) == ["Qwen [beta]", "x [/i] y"] + await pilot.press("down", "enter") + await _settle(pilot, lambda: app._model_override is not None) + assert app._model_override == "italic" + + +@pytest.mark.asyncio +async def test_select_screen_title_is_shown_as_written(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(SelectScreen("Pick [/b] one", [("only", "Only")])) + assert await _settle(pilot, lambda: isinstance(app.screen, SelectScreen)) + title = app.screen.query_one("#dialog-title") + assert title.render_line(0).text.strip() == "Pick [/b] one"