mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(tui): accept whitespace separators in slash commands (#7117)
* fix(tui): accept whitespace separators in slash commands * fix(tui): align slash palette with Unicode whitespace Close the slash palette on every whitespace separator accepted by the command resolver, including pasted tabs and Unicode spaces. Document the accepted delimiters and cover parser arguments, palette closure, Enter submission and Tab completion. Validation: 352 focused tests, 34,089 offline backend tests and 262 blocking-I/O tests passed. Backend formatting, frontend check and agent guidance passed. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
a83aa36309
commit
68d4e5527c
@ -3443,6 +3443,11 @@ Headless `--print` and `--json` exit with status `1` when the run fails, includi
|
||||
|
||||
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, display-only `/clear`, `/goal` goal management, `/model` and `/threads` pickers, input history, PageUp/PageDown transcript navigation, and `Esc` / `Ctrl+C` interrupt. The composer preserves line breaks and indentation in pasted code, stack traces, and multi-paragraph prompts; `Enter` sends the complete document. Transcript refreshes preserve your reading position after you scroll upward and resume following new output when you return to the bottom. `/clear` removes rows from the current terminal display without deleting the thread or its persisted conversation; `/new` and `/clear` ask you to wait during an active run instead of resetting in-flight display state. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
|
||||
|
||||
TUI slash commands accept Unicode whitespace before their arguments, including
|
||||
spaces, tabs, line breaks, and nonbreaking spaces. A separator closes the slash
|
||||
palette so Enter submits the composed text. Pasted skill tasks can start on the
|
||||
next line; their internal line breaks and indentation are preserved.
|
||||
|
||||
During an active run, `/resume`, `/threads`, and `/switch` ask you to wait before
|
||||
switching conversations. An invalid `/resume` reference displays an error without
|
||||
closing the TUI or changing the current conversation.
|
||||
|
||||
@ -13,6 +13,14 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
|
||||
|
||||
**Web UI visibility**: the Web UI lists threads from the `threads_meta` SQL table (user-scoped), not the checkpointer. `persistence.py` writes a `threads_meta` row under the default user (`"default"`) into the same DB the Gateway reads — via the harness-only `deerflow.persistence.engine.init_engine_from_config()` — so TUI sessions appear in the Web UI sidebar **without** running the Gateway. Best-effort: a no-op on the `memory` backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop).
|
||||
|
||||
`resolve()` splits the slash token from its arguments on Unicode whitespace,
|
||||
including tabs, newlines and nonbreaking spaces, without collapsing whitespace
|
||||
inside the arguments. The name must remain attached to `/`; skill submissions
|
||||
retain internal line breaks and indentation. The palette uses the same whitespace
|
||||
boundary: a pasted separator closes it so Enter submits instead of completing
|
||||
the command. The Tab key still completes the highlighted palette item. Keep
|
||||
reserved-command checks and paste-to-submit regression coverage.
|
||||
|
||||
A persistence bridge timeout requests cancellation of its still-pending coroutine.
|
||||
This is cooperative cancellation, not rollback of a committed write or ordering
|
||||
of normally completed title writes. Preserve coroutine-originated errors and
|
||||
|
||||
@ -299,7 +299,7 @@ class DeerFlowTUI(App):
|
||||
self._close_palette()
|
||||
return
|
||||
self._restored_draft = None
|
||||
if value.startswith("/") and " " not in value and "\n" not in value:
|
||||
if value.startswith("/") and not any(char.isspace() for char in value):
|
||||
from .command_registry import build_registry, filter_commands
|
||||
|
||||
items = filter_commands(build_registry(self._skills_meta), value[1:])
|
||||
|
||||
@ -113,10 +113,9 @@ def resolve(text: str, skills: list[str] | None = None) -> Resolution:
|
||||
if not stripped.startswith("/"):
|
||||
return Resolution(kind="message", text=text)
|
||||
|
||||
body = stripped[1:]
|
||||
name, _, args = body.partition(" ")
|
||||
name = name.strip()
|
||||
args = args.strip()
|
||||
parts = stripped.split(maxsplit=1)
|
||||
name = parts[0][1:]
|
||||
args = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if not name:
|
||||
return Resolution(kind="unknown", name="")
|
||||
|
||||
@ -84,21 +84,48 @@ async def test_app_runs_a_turn_and_renders_streamed_assistant():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_paste_reaches_agent_without_dropping_lines():
|
||||
@pytest.mark.parametrize("prefix", ["", "/tdd\n", "/tdd\t"])
|
||||
async def test_multiline_paste_reaches_agent_without_dropping_lines(prefix):
|
||||
session = _FakeSession()
|
||||
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
||||
pasted = 'Traceback:\n File "worker.py", line 7\nRuntimeError: boom'
|
||||
pasted = prefix + 'Traceback:\n File "worker.py", line 7\nRuntimeError: boom'
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app.post_message(events.Paste(pasted))
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
|
||||
assert await _wait_until(lambda: bool(session.client.stream_calls), pilot)
|
||||
|
||||
assert session.client.stream_calls[0][0] == pasted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("separator", [" ", "\t", "\n", "\u00a0", "\u2003", "\u202f", "\u3000"])
|
||||
async def test_pasted_whitespace_closes_palette_and_enter_submits_skill(separator):
|
||||
session = _FakeSession()
|
||||
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
||||
pasted = f"/tdd{separator}"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("slash", "t", "d", "d")
|
||||
assert app._palette_open
|
||||
app.post_message(events.Paste(separator))
|
||||
await pilot.pause()
|
||||
assert app.query_one("#composer").value == pasted
|
||||
palette_open_after_separator = app._palette_open
|
||||
|
||||
await pilot.press("enter")
|
||||
assert await _wait_until(lambda: bool(session.client.stream_calls), pilot)
|
||||
assert not palette_open_after_separator
|
||||
assert not app._palette_open
|
||||
|
||||
assert len(session.client.stream_calls) == 1
|
||||
assert session.client.stream_calls[0][0] == "/tdd"
|
||||
assert app._history.entries() == ["/tdd"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_arrows_move_lines_before_input_history():
|
||||
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Tests for the slash-command registry (pure)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES
|
||||
from deerflow.tui.command_registry import (
|
||||
BUILTIN_COMMANDS,
|
||||
@ -78,6 +80,23 @@ def test_resolve_builtin_with_args():
|
||||
assert res.args == "thread-123"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", [" ", "\t", "\n", "\r\n", "\v", "\f", "\u0085", "\u00a0", "\u2003", "\u2028", "\u202f", "\u3000"])
|
||||
@pytest.mark.parametrize(("name", "kind"), [("resume", "builtin"), ("goal", "builtin"), ("tdd", "skill")])
|
||||
def test_resolve_whitespace_separated_command_preserves_multiline_args(separator, name, kind):
|
||||
args = "First line\n indented detail\nLast line"
|
||||
|
||||
res = resolve(f"/{name}{separator}{args}", skills=["tdd", "goal"])
|
||||
|
||||
assert res.kind == kind
|
||||
assert res.name == name
|
||||
assert res.args == args
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["/ tdd task", "/\ttdd task", "/\ntdd task", "/context\ncompact", "/missing\ntask"])
|
||||
def test_whitespace_does_not_admit_invalid_or_reserved_skill_commands(text):
|
||||
assert resolve(text, skills=["tdd", "context"]).kind == "unknown"
|
||||
|
||||
|
||||
def test_resolve_skill_activation():
|
||||
res = resolve("/tdd write the test first", skills=["tdd"])
|
||||
assert res.kind == "skill"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user