Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: TUI multi-line message fix — composer per-line render + preserve line breaks in user markdown (rant 2026-08-19T14:25:55) by argszero · Pull Request #870 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
63 changes: 45 additions & 18 deletions emrg/client/python_tui/widgets/composer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
29 changes: 28 additions & 1 deletion emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "> "
Expand All@@ -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):
Expand All@@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_composer.py
Original file line numberDiff line numberDiff line change
@@ -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("> ")
39 changes: 39 additions & 0 deletions tests/test_user_markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "
Loading