fix(tui): keep a refused message in the composer (#6666)

* fix(tui): keep a refused message in the composer

on_composer_input_submitted cleared the composer before _send_to_agent
decided whether it could start a run. A prompt typed during an active run,
while an interrupted run on the same thread was still stopping, or when the
worker failed to start was replaced by a notice and survived only in input
history.

_send_to_agent now reports whether it started a run, and the submit handler
puts the exact submitted text back with the cursor at its end, so Enter
resends it. Accepted messages and local slash commands clear the composer as
before.

* fix(tui): keep a launch message that fails to start in the composer

on_mount sent the launch message through _send_to_agent and ignored the
result, so a worker start failure lost the message: it never reached the
composer or input history. Route both the launch message and composer
submissions through one _restore_draft helper.

* docs(changelog): reference #6666 for the TUI refused-message fix

* fix(tui): keep the palette closed over a restored slash draft

Restoring a refused bare skill activation such as /tdd posted
TextArea.Changed, which reopened the command palette, so Enter filled the
skill instead of resending it. Remember the restored draft and keep the
palette closed until it is edited. Restore the history copy of the text too,
so a trimmed slash command matches what Up recalls.
This commit is contained in:
Hyeonsang Cho 2026-10-11 15:44:27 +09:00 committed by GitHub
parent a68e3064e1
commit 2ca0b79f73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 168 additions and 11 deletions

View File

@ -4156,6 +4156,16 @@ This release closes that milestone with **439 merged pull requests**.
missing; `verify-versions.yml` installs the uv version the backend image pins.
([#6588])
- **tui:** A message the TUI refuses to send now stays in the composer. Submitting
cleared the composer before the send was checked, so a prompt typed during an
active run, while an interrupted run was still stopping, or when the run could
not start was replaced by a notice and survived only in input history; a
command-line launch message whose run could not start was lost entirely. The
composer now gets the text back as input history recorded it, with the cursor
at its end and the command palette closed, so `Enter` resends it; accepted
messages and local slash commands clear it as before.
([#6666])
- **tui:** `deerflow --print` and `--json` no longer drop words of an unquoted
message. Each flag took a single value and never read the positional words,
so `deerflow --print summarize this repo` sent only `summarize`,
@ -9577,3 +9587,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6613]: https://github.com/bytedance/deer-flow/pull/6613
[#6635]: https://github.com/bytedance/deer-flow/pull/6635
[#6636]: https://github.com/bytedance/deer-flow/pull/6636
[#6666]: https://github.com/bytedance/deer-flow/pull/6666

View File

@ -3193,6 +3193,13 @@
规范化交给 uv,`2.1.0-rc0` 仍与 `2.1.0rc0` 匹配),缺少 `uv` 时直接失败;
`verify-versions.yml` 会安装与 backend 镜像相同的固定 uv 版本。([#6588])
- **TUI:** TUI 拒绝发送的消息现在会保留在输入框中。此前提交时会先清空输入框再检查能否发送,
因此在运行进行中、被中断的运行尚未停止,或运行无法启动时输入的提示会被一条提示信息取代,
只能在输入历史中找回;命令行传入的启动消息在运行无法启动时则会完全丢失。
现在输入框会按输入历史记录的文本恢复消息,把光标放在末尾并保持命令面板关闭,
按 `Enter` 即可重新发送;
被接受的消息和本地斜杠命令仍会像以前一样清空输入框。([#6666])
- **TUI:** `deerflow --print` 和 `--json` 不再丢弃未加引号消息中的词。此前这两个
选项只接收一个值,也从不读取位置参数,因此 `deerflow --print summarize this repo`
只会发送 `summarize`,`deerflow summarize this repo --print` 在终端中会以退出码 2
@ -7700,3 +7707,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6613]: https://github.com/bytedance/deer-flow/pull/6613
[#6635]: https://github.com/bytedance/deer-flow/pull/6635
[#6636]: https://github.com/bytedance/deer-flow/pull/6636
[#6666]: https://github.com/bytedance/deer-flow/pull/6666

View File

@ -2915,6 +2915,7 @@ A tool call already in progress still finishes, so until it does a new prompt in
that conversation is held back with a notice instead of racing it; `/new` and
`/resume` stay available.
If a run cannot start, the TUI reports an error and returns to idle so you can retry.
A message that is held back or cannot start stays in the composer, so `Enter` resends it.
At the last composer row, `Down` leaves an unsent draft untouched unless you are
browsing input history; after recalling history, it moves forward to restore your

View File

@ -22,6 +22,10 @@ Reserve the run identity and busy state when accepting input, before its worker
starts. Each run owns its cancellation flag; a later send must not reset it.
If worker creation fails, cancel and clear that reserved run, restore idle state,
and show a retryable error. Do not cancel a previously completed run's title write.
`_send_to_agent` returns False whenever it does not start a run (busy, a stopping
interrupted run, worker start failure); the submit handler and the launch message
then restore the text as history recorded it via `_restore_draft`, which keeps the
palette closed until that draft is edited. Local slash commands still clear it.
Worker callbacks check both run identity and thread id on UI delivery, rejecting
cancelled, completed, or superseded runs before updating the reducer. Keep the
completed run's state available for its worker's title persistence check. An

View File

@ -216,6 +216,7 @@ class DeerFlowTUI(App):
self._palette_open = False
self._palette_items: list = []
self._palette_index = 0
self._restored_draft: str | None = None
self._history = InputHistory()
self._transcript_dirty = False
self._transcript_scroll_pending = False
@ -241,8 +242,8 @@ class DeerFlowTUI(App):
self.set_interval(0.1, self._tick_spinner)
self.set_interval(0.06, self._flush_transcript) # coalesce streaming re-renders
self.query_one("#composer", ComposerInput).focus()
if self.plan and getattr(self.plan, "message", None):
self._send_to_agent(self.plan.message)
if self.plan and getattr(self.plan, "message", None) and not self._send_to_agent(self.plan.message):
self._restore_draft(self.plan.message)
# ----- session info -------------------------------------------------- #
@ -276,13 +277,28 @@ class DeerFlowTUI(App):
if not stripped:
return
self._history.add(text)
self._handle_submit(text)
if not self._handle_submit(text):
self._restore_draft(text)
def _restore_draft(self, text: str) -> None:
# A message that did not start is still the user's draft: put it back so
# Enter resends it, instead of leaving it only in input history. A bare
# "/skill" would reopen the palette, where Enter fills instead of sends,
# so the palette stays closed until the draft is edited.
self._restored_draft = text
composer = self.query_one("#composer", ComposerInput)
composer.value = text
composer.cursor_position = len(text)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not isinstance(event.text_area, ComposerInput):
return
value = event.text_area.value
event.text_area.set_class("\n" in value, "multiline")
if value == self._restored_draft:
self._close_palette()
return
self._restored_draft = None
if value.startswith("/") and " " not in value and "\n" not in value:
from .command_registry import build_registry, filter_commands
@ -423,19 +439,20 @@ class DeerFlowTUI(App):
composer.cursor_position = len(composer.value)
self._close_palette()
def _handle_submit(self, text: str) -> None:
def _handle_submit(self, text: str) -> bool:
"""Route a submission; return False when a message for the agent did not start."""
from .command_registry import resolve
res = resolve(text, skills=self._skill_names)
if res.kind == "builtin":
self._handle_builtin(res.name, res.args)
return
return True
if res.kind == "unknown":
self._dispatch(SystemMessage(f"Unknown command /{res.name}. Try /help.", tone="error"))
return
return True
# plain message or skill activation (/skill task) both go to the agent,
# which applies skill-activation semantics on the raw text.
self._send_to_agent(text)
return self._send_to_agent(text)
def _dispatch_still_working(self) -> None:
self._dispatch(SystemMessage("Still working — wait for the current run to finish.", tone="info"))
@ -658,10 +675,11 @@ class DeerFlowTUI(App):
# ----- agent run ----------------------------------------------------- #
def _send_to_agent(self, text: str) -> None:
def _send_to_agent(self, text: str) -> bool:
"""Start a run for ``text``; return False when it was refused or failed to start."""
if self._streaming:
self._dispatch_still_working()
return
return False
# An interrupted worker cannot be killed: it stops at its next stream
# event, so a long tool call keeps running and then checkpoints. A new
# run on the same thread would race it, and whichever checkpoint lands
@ -669,7 +687,7 @@ class DeerFlowTUI(App):
self._interrupted_runs = [run for run in self._interrupted_runs if run.worker_running()]
if any(run.thread_id == self._conv_thread_id for run in self._interrupted_runs):
self._dispatch(SystemMessage("The interrupted run is still stopping on this thread. Send again once it finishes, or use /new or /resume to continue elsewhere.", tone="info"))
return
return False
if self._conv_thread_id is None:
self._conv_thread_id = str(uuid.uuid4())
run = _Run(self._conv_thread_id)
@ -688,6 +706,8 @@ class DeerFlowTUI(App):
self._run = None
self._streaming = False
self._dispatch(SystemMessage("Could not start the run. Please try again.", tone="error"))
return False
return True
def _stream_worker(self, text: str, run: _Run) -> None:
# Mark the start before checking cancellation: an interrupt either

View File

@ -637,6 +637,94 @@ async def test_whitespace_only_input_is_not_submitted():
assert app._history.entries() == []
@pytest.mark.asyncio
async def test_message_refused_during_a_run_stays_in_the_composer():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = " def f():\n return 1\n"
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == pasted
assert composer.cursor_position == len(pasted)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
assert session.client.stream_calls == []
app._streaming = False
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert composer.value == ""
assert session.client.stream_calls[0][0] == pasted
assert app._history.entries() == [pasted]
@pytest.mark.asyncio
async def test_refused_bare_skill_activation_is_restored_without_reopening_the_palette():
"""Restoring "/tdd" must not reopen the palette, or Enter would fill it instead of resending."""
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
await pilot.press("/", "t", "d", "d", "escape", "enter")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == "/tdd"
assert app._palette_open is False
app._streaming = False
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert composer.value == ""
assert session.client.stream_calls[0][0] == "/tdd"
@pytest.mark.asyncio
async def test_editing_a_restored_slash_draft_reopens_the_palette():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
await pilot.press("/", "t", "d", "d", "escape", "enter")
await pilot.pause()
await pilot.press("backspace")
await pilot.pause()
assert app.query_one("#composer").value == "/td"
assert app._palette_open is True
# Once edited, the draft is ordinary input again, even if retyped back.
await pilot.press("d")
await pilot.pause()
assert app.query_one("#composer").value == "/tdd"
assert app._palette_open is True
@pytest.mark.asyncio
async def test_refused_skill_activation_is_restored_as_recorded_in_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
app.post_message(events.Paste(" /tdd write tests "))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert app.query_one("#composer").value == "/tdd write tests"
assert app._history.entries() == ["/tdd write tests"]
@pytest.mark.asyncio
async def test_down_on_last_line_of_multiline_input_falls_back_to_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))

View File

@ -123,10 +123,11 @@ async def test_send_waits_for_the_interrupted_run_before_reusing_its_thread():
assert any(STILL_STOPPING in text for text in _system_texts(app))
assert [message for message, _ in client.calls] == ["old question"]
assert not app._streaming
assert app.query_one("#composer").value == "new question"
client.release_old_tool.set()
await _settle(pilot, lambda: "old question" in client.finished and app._run.stopped.is_set())
await _submit(app, pilot, "new question")
await pilot.press("enter")
await _settle(pilot, lambda: "new question" in client.finished and not app._streaming)
finally:
client.release_old_tool.set()

View File

@ -212,6 +212,7 @@ async def test_send_reserves_busy_state_before_worker_starts(monkeypatch):
app.query_one("#composer").value = "new question"
await pilot.press("enter")
assert any(row.kind == "system" and "Still working" in row.text for row in app.state.rows)
assert app.query_one("#composer").value == "new question"
await pilot.press("ctrl+c")
release.set()
client.release_old.set()
@ -249,6 +250,7 @@ async def test_worker_start_failure_restores_idle_state_and_allows_retry(monkeyp
assert any(row.kind == "system" and row.tone == "error" for row in app.state.rows)
assert "private worker startup detail" not in str(app.state.rows)
assert client.calls == []
assert app.query_one("#composer").value == "old question"
idle_state = app.state
for action in [RunStarted(), AssistantDelta(id="failed-answer", text="stale"), ThreadTitle("Failed title"), RunEnded(usage={"total_tokens": 999})]:
@ -267,6 +269,28 @@ async def test_worker_start_failure_restores_idle_state_and_allows_retry(monkeyp
assert not any(row.kind == "system" and "Still working" in row.text for row in app.state.rows)
@pytest.mark.asyncio
async def test_launch_message_that_fails_to_start_stays_in_the_composer(monkeypatch):
client = _Client()
app = DeerFlowTUI(Session(client=client), LaunchPlan(mode="tui", thread_id="thread-a", message="launch question"))
original_start = app.run_worker
def fail_agent_worker(*args, **kwargs):
if kwargs.get("group") == "agent":
raise RuntimeError("Cannot start the launch worker")
return original_start(*args, **kwargs)
monkeypatch.setattr(app, "run_worker", fail_agent_worker)
async with app.run_test() as pilot:
await pilot.pause()
assert not app._streaming
assert any(row.kind == "system" and row.tone == "error" for row in app.state.rows)
composer = app.query_one("#composer")
assert composer.value == "launch question"
assert composer.cursor_position == len("launch question")
assert client.calls == []
@pytest.mark.asyncio
async def test_completed_run_rejects_a_duplicate_terminal_callback(monkeypatch):
client, writer = _Client(), _Writer()