diff --git a/README.md b/README.md index 10aa1323b8..56e1ff65f6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index a0e68cd604..f5c24a969c 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index f1ac19bc55..6904051d8d 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -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:]) diff --git a/backend/packages/harness/deerflow/tui/command_registry.py b/backend/packages/harness/deerflow/tui/command_registry.py index 86887e848b..93e9073794 100644 --- a/backend/packages/harness/deerflow/tui/command_registry.py +++ b/backend/packages/harness/deerflow/tui/command_registry.py @@ -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="") diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index a8bd167b04..1d152f5ee0 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -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")) diff --git a/backend/tests/test_tui_command_registry.py b/backend/tests/test_tui_command_registry.py index b7fbcc1a47..c62b29a41f 100644 --- a/backend/tests/test_tui_command_registry.py +++ b/backend/tests/test_tui_command_registry.py @@ -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"