diff --git a/Agent.md b/Agent.md index e177d0e2..22bd822b 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (968) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (977) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/client/python_tui/widgets/composer.py b/emrg/client/python_tui/widgets/composer.py index 74ee475c..306dcef2 100644 --- a/emrg/client/python_tui/widgets/composer.py +++ b/emrg/client/python_tui/widgets/composer.py @@ -140,28 +140,55 @@ def submit(self) -> str | None: return text def render(self, ctx: RenderContext) -> list[Line]: - """Render the composer with prompt, text, and cursor indicator.""" + """Render the composer with prompt, text, and cursor indicator. + + Multi-line text (pasted input) renders as one Line per logical line: + the first line carries the prompt, continuation lines a same-width + indent, and the cursor is drawn on the line it currently sits in + (rant 2026-08-19T14:25:55 — a single Line with embedded ``\\n`` was + flattened by the buffer, which skips newline characters). + """ is_placeholder = not self._text - # Show cursor position - if self._text and self._cursor < len(self._text): - cursor_char = self._text[self._cursor] - prefix = self._text[:self._cursor] - suffix = self._text[self._cursor + 1:] - else: - cursor_char = " " - prefix = self._text - suffix = "" - - cursor_style = "reverse" if self._text else "dim" - style = "dim" if is_placeholder else "" - + if self._text: + content_lines = self._text.split("\n") + indent = " " * len(self.prompt) + lines_out: list[Line] = [] + line_start = 0 + for i, line_text in enumerate(content_lines): + line_end = line_start + len(line_text) + # Cursor lives in this line iff it is within [line_start, line_end]. + cursor_here = line_start <= self._cursor <= line_end + if cursor_here: + rel = self._cursor - line_start + if rel < len(line_text): + cursor_char = line_text[rel] + prefix = line_text[:rel] + suffix = line_text[rel + 1:] + else: # cursor at end of this line (incl. on the newline) + cursor_char = " " + prefix = line_text + suffix = "" + else: + cursor_char = " " + prefix = line_text + suffix = "" + lines_out.append(Line(spans=[ + Span(text=self.prompt if i == 0 else indent, + style="bold cyan" if i == 0 else "dim"), + Span(text=prefix, style="" if not is_placeholder else "dim"), + Span(text=cursor_char, style="reverse"), + Span(text=suffix, style="" if not is_placeholder else "dim"), + ], style=ctx.style)) + line_start = line_end + 1 # skip the newline separator + self._dirty = False + return lines_out + + # Empty / placeholder: single line with prompt + dim cursor block. spans = [ Span(text=self.prompt, style="bold cyan"), - Span(text=prefix, style=style), - Span(text=cursor_char, style=cursor_style), - Span(text=suffix, style=style), + Span(text=" ", style="dim"), + Span(text=" ", style="dim"), ] - self._dirty = False return [Line(spans=spans, style=ctx.style)] diff --git a/emrg/client/python_tui/widgets/markdown.py b/emrg/client/python_tui/widgets/markdown.py index e90d58dc..85ed074d 100644 --- a/emrg/client/python_tui/widgets/markdown.py +++ b/emrg/client/python_tui/widgets/markdown.py @@ -56,6 +56,10 @@ class UserMarkdown(Markdown): ``> `` prefix + bold cyan role visual. The markdown is rendered at ``ctx.width - len(prefix)`` so the prefix on the first line never overflows the buffer width (continuation lines get a same-width indent). + + Single newlines are preserved as hard line breaks (rant + 2026-08-19T14:25:55): Rich would otherwise collapse ``\\n`` into a + space, merging pasted multi-line messages into one wrapped line. """ _ROLE_PREFIX = "> " @@ -72,7 +76,7 @@ def render(self, ctx: RenderContext) -> list[Line]: role_style = Style.parse(self._ROLE_STYLE) avail = max(1, ctx.width - len(prefix)) - md = RichMarkdown(self.text, code_theme="monokai") + md = RichMarkdown(_preserve_line_breaks(self.text), code_theme="monokai") md_lines = rich_renderable_to_lines(md, avail) lines: list[Line] = [] for i, line in enumerate(md_lines): @@ -84,6 +88,29 @@ def render(self, ctx: RenderContext) -> list[Line]: return lines +def _preserve_line_breaks(text: str) -> str: + """Turn single newlines into hard breaks so RichMarkdown keeps them. + + Rich collapses a single ``\\n`` (markdown soft break) into a space, so + pasted multi-line user messages render as one long auto-wrapped line. + A CommonMark hard break is a line ending in two spaces — Rich renders + each such line separately. Blank lines (paragraph separators) and the + interior of fenced code blocks are left untouched: trailing whitespace + is significant inside code blocks. + """ + out: list[str] = [] + in_fence = False + for line in text.split("\n"): + if line.lstrip().startswith("```"): + in_fence = not in_fence + out.append(line) + elif in_fence or not line.strip(): + out.append(line) + else: + out.append(line + " ") + return "\n".join(out) + + @dataclass class StreamingMarkdown(Widget): """Incremental markdown renderer for token-by-token streaming. diff --git a/tests/test_composer.py b/tests/test_composer.py new file mode 100644 index 00000000..7ad9d6da --- /dev/null +++ b/tests/test_composer.py @@ -0,0 +1,87 @@ +"""Tests for the Composer widget (multi-line input rendering). + +rant 2026-08-19T14:25:55: pasted multi-line text must render as one line +per logical line — the first line carries the prompt, continuation lines a +same-width indent, and the cursor is drawn on the line it currently sits +in. (The old single-Line render was flattened by the buffer, which skips +\\n characters → long auto-wrapped blob.) +""" + +from __future__ import annotations + +from emrg.client.python_tui.widgets.base import RenderContext +from emrg.client.python_tui.widgets.composer import Composer + + +def _render_text(text: str, cursor: int | None = None) -> list[str]: + composer = Composer() + if text: + composer._text = text + composer._cursor = len(text) if cursor is None else cursor + ctx = RenderContext(width=80) + lines = composer.render(ctx) + return ["".join(s.text for s in line.spans) for line in lines] + + +def test_composer_single_line(): + """Single-line input keeps the prompt on the only line.""" + out = _render_text("hello") + assert len(out) == 1 + assert out[0].startswith("> ") + assert "hello" in out[0] + + +def test_composer_multiline_one_line_per_logical_line(): + """Multi-line text renders one Line per logical line (rant 14:25:55).""" + out = _render_text("line1\nline2\nline3") + assert len(out) == 3 + assert out[0].startswith("> ") + assert out[0][2:].strip().startswith("line1") + # Continuation lines: same-width indent, no prompt symbol + assert out[1].startswith(" ") + assert out[1].lstrip().startswith("line2") + assert out[2].startswith(" ") + assert out[2].lstrip().startswith("line3") + + +def test_composer_multiline_no_prompt_on_continuation(): + """Only the first line carries '> '; continuation uses indent only.""" + out = _render_text("a\nb") + assert out[0].startswith("> ") + assert out[1].startswith(" ") + assert not out[1].lstrip().startswith(">") + assert out[1].strip() == "b" + + +def test_composer_cursor_mid_first_line(): + """Cursor in the middle of the first line is drawn there.""" + composer = Composer() + composer._text = "abcd\nef" + composer._cursor = 2 # between 'ab' and 'cd' + ctx = RenderContext(width=80) + lines = composer.render(ctx) + first = "".join(s.text for s in lines[0].spans) + # prompt '> ' + prefix 'ab' + cursor 'c' + suffix 'd' + assert first == "> abcd" + assert lines[0].spans[2].text == "c" # the cursor span + + +def test_composer_cursor_on_second_line(): + """Cursor on a continuation line is drawn there, first line stays plain.""" + composer = Composer() + composer._text = "ab\ncdef" + composer._cursor = 3 + 1 # 'a'=0,'b'=1,'\n'=2,'c'=3 → cursor at 'd' (idx 4? no) + # offsets: a0 b1 \n2 c3 d4 e5 f6 → cursor 4 = 'd' + composer._cursor = 4 + ctx = RenderContext(width=80) + lines = composer.render(ctx) + second = "".join(s.text for s in lines[1].spans) + # prefix 'c' + cursor 'd' + suffix 'ef' + assert second.lstrip().startswith("cd") + + +def test_composer_empty_placeholder(): + """Empty composer renders a single dim prompt line.""" + out = _render_text("") + assert len(out) == 1 + assert out[0].startswith("> ") diff --git a/tests/test_user_markdown.py b/tests/test_user_markdown.py index 8e8090e1..bbd2e643 100644 --- a/tests/test_user_markdown.py +++ b/tests/test_user_markdown.py @@ -100,3 +100,42 @@ def test_chat_history_update_last_updates_user_markdown(): chat.update_last("updated text") assert isinstance(chat.rows[0], UserMarkdown) assert chat.rows[0].text == "updated text" + + +def test_user_markdown_preserves_single_newlines(): + """Pasted multi-line text keeps its line breaks (rant 2026-08-19T14:25:55). + + RichMarkdown would collapse single \\n into spaces; the hard-break + preprocessing must keep each logical line separate with "> " only on + the first line and a same-width indent on continuation lines. + """ + visible = _render("line1\nline2\nline3", 40) + assert len(visible) == 3 + assert visible[0].startswith("> ") + assert "line1" in visible[0] + assert visible[1].startswith(" ") + assert "line2" in visible[1] + assert visible[2].startswith(" ") + assert "line3" in visible[2] + + +def test_user_markdown_preserves_blank_lines_as_paragraphs(): + """Blank lines still act as paragraph separators (\\n\\n unchanged).""" + visible = _render("para one\n\npara two", 40) + # two paragraphs → at least 2 rendered lines, second starts fresh + assert len(visible) >= 2 + assert "para one" in visible[0] + assert "para two" in visible[-1] + + +def test_preserve_line_breaks_skips_fenced_code_blocks(): + """Lines inside ``` fences are not given trailing hard-break spaces.""" + from emrg.client.python_tui.widgets.markdown import _preserve_line_breaks + + text = "intro\n```\ncode line 1\ncode line 2\n```\noutro" + out = _preserve_line_breaks(text) + parts = out.split("\n") + assert parts[0] == "intro " # plain line → hard break + assert parts[2] == "code line 1" # fence interior untouched + assert parts[3] == "code line 2" + assert parts[5] == "outro "