mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 22:20:35 +00:00
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:
parent
a7d8971078
commit
f6a8f076ed
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user