fix(tui): show picker labels as written instead of parsing markup (#7631)

* fix(tui): show picker labels as written instead of parsing markup

SelectScreen passed thread titles and model display names to Textual as
plain str, which Textual parses as content markup. A thread title such as
'Fix [/bold] in parser' raised MarkupError and exited the app whenever
/threads opened, and '[a]' vanished from the list. Wrap the title and
option labels in Rich Text so they render literally.

* docs(changelog): reference #7631 for the TUI picker markup fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-28 21:23:31 +09:00 committed by GitHub
parent a7d8971078
commit f6a8f076ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 91 additions and 5 deletions

View File

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

View File

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

View File

@ -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
`/<skill-name> 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.

View File

@ -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 <title>` read `DeerFlowClient.list_threads()`, where `limit` counts threads (newest created first); `--continue` passes `sort_by="updated_at"` to resume the most recently active thread.

View File

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

View File

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