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:
杰尼龟 2026-10-19 23:19:45 -04:00 committed by GitHub
parent a83aa36309
commit 68d4e5527c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 66 additions and 8 deletions

View File

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

View File

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

View File

@ -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:])

View File

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

View File

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

View File

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