diff --git a/Agent.md b/Agent.md index 3448f791..be575394 100644 --- a/Agent.md +++ b/Agent.md @@ -112,7 +112,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` (695) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (703) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/app.py b/emrg/client/app.py index 1af44b72..b21cc740 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -211,14 +211,20 @@ async def interactive(init_auto_evolve: bool = False): term = Terminal(); stdin_fd = sys.stdin.fileno() stdin_queue: asyncio.Queue = asyncio.Queue() - def _status_left(title: str, sid: str) -> str: - """Format left status: show both name and short ID for renamed sessions.""" + def _status_left(title: str, sid: str, model: str = "") -> str: + """Format left status: session title + short ID + current model.""" + parts = [] if title: - return f"{title} ({sid[:8]})" - return sid + parts.append(f"{title} ({sid[:8]})") + else: + parts.append(sid) + if model: + parts.append(f"[{model}]") + return " ".join(parts) busy = False; server_id = ""; need_new_assistant = False; session_title = "" + current_model = "" # model name tracked independently of server_id (rant 2026-08-11T20:02:43) - status = StatusLine(left=_status_left(session_title, session_id), center="connecting...") + status = StatusLine(left=_status_left(session_title, session_id, current_model), center="connecting...") inp = InputWidget(); chat = ChatHistory() term.mount(status=status, composer=inp, chat=chat) @@ -240,12 +246,12 @@ def _short_path(p: str) -> str: p = "…" + p[-29:] return p - def _update_right() -> None: + def _update_left_extra() -> None: if msg_count > 0: - status.update(right=f"{msg_count} msgs {_short_path(cwd)}") + status.update(left_extra=f"· {msg_count} msgs · {_short_path(cwd)}") else: - status.update(right="Enter=send Esc=quit /help") - _update_right() + status.update(left_extra="Enter=send Esc=quit /help") + _update_left_extra() _status_base: str = "" # base center text without timer, for elapsed timer overlay _last_center: str = "" # last center text set via status.update, for timer overlay @@ -257,7 +263,7 @@ async def _run_elapsed_timer() -> None: try: elapsed = int(time.time() - _request_start) mins, secs = divmod(elapsed, 60) - timer = f"⏱{mins}:{secs:02d}" if mins > 0 else f"⏱{secs}s" + timer = f"[{mins}:{secs:02d}]" status.elapsed = timer term.set_title(f"{timer} {session_title or session_id} @ {project_name}") term.render() @@ -295,6 +301,7 @@ def _render_throttled(): async def read_server(): nonlocal stream_buffer, status, history, chat, busy, server_id, need_new_assistant, session_id, session_title, msg_count, tool_args, _welcomed + nonlocal current_model nonlocal _last_center, _elapsed_task, conn async def _reconnect(): @@ -348,9 +355,9 @@ async def _reconnect(): ident = data.get("identity", {}); hid = ident.get("instance_id", "?")[:8] host = ident.get("host_name", "?") model = data.get("model", "") - server_id = f"{hid} @ {host}" if model: - server_id += f" [{model}]" + current_model = model + server_id = f"{hid} @ {host}" if not _welcomed: _welcomed = True import emrg @@ -363,7 +370,7 @@ async def _reconnect(): await conn.send_command("update_check") except Exception: pass # never block chat on update check - status.update(left=_status_left(session_title, session_id), center=server_id) + status.update(left=_status_left(session_title, session_id, current_model), center=server_id) term.set_title(f"{session_title or session_id} @ {project_name}") term.render(); continue @@ -473,7 +480,7 @@ async def _reconnect(): _last_center = server_id or "emrg" status.update(center=_last_center) term.set_title(f"{session_title or session_id} @ {project_name}") - msg_count += 1; _update_right() + msg_count += 1; _update_left_extra() term.render() if "error" in data: err = data["error"]; logger.error("server error: %s", err) @@ -490,7 +497,7 @@ async def _reconnect(): chat.dirty = True chat.add("system", "Session cleared — starting fresh.") msg_count = 0 - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -511,10 +518,10 @@ async def _reconnect(): chat.rows.clear() chat.dirty = True chat.add("system", f"Created new session {new_sid} — continue chatting.") - status.update(left=_status_left("", new_sid), center=server_id or "emrg") + status.update(left=_status_left("", new_sid, current_model), center=server_id or "emrg") term.set_title(f"{new_sid} @ {project_name}") msg_count = 0 - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -533,7 +540,7 @@ async def _reconnect(): msg_count = 0 # Reload session state from server await conn.send_command("ping") - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -555,7 +562,7 @@ async def _reconnect(): ) busy = False msg_count = max(0, msg_count - compacted) - _update_right() + _update_left_extra() status.elapsed = "" status.update(center=server_id or "emrg"); term.render() continue @@ -685,10 +692,9 @@ async def _reconnect(): chat.add("system", f"Model switched: {previous} → {model_name}" f" (context: {ctx_win:,})") - # Update server_id so all subsequent status updates show the new model - base_id = server_id.split(" [")[0] if " [" in server_id else server_id - server_id = f"{base_id} [{model_name}]" if base_id else f"emrg [{model_name}]" - status.update(center=server_id) + # Track model independently and refresh the left section + current_model = model_name + status.update(left=_status_left(session_title, session_id, current_model), center=server_id) term.render() continue @@ -860,11 +866,11 @@ async def _reconnect(): f"Resumed session {session_id}{title_extra} " f"({meta.get('message_count', record_count)} messages, " f"created {str(meta.get('created_at', ''))[:16].replace('T', ' ')})") - status.update(left=_status_left(session_title, session_id), center=server_id or "emrg") + status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg") term.set_title(f"{session_title or session_id} @ {project_name}") # Set message count from loaded session msg_count = meta.get("message_count", record_count) - _update_right() + _update_left_extra() term.render() continue @@ -877,7 +883,7 @@ async def _reconnect(): new_title = data.get("title", "") session_title = new_title chat.add("system", f"Session renamed to: {new_title}") - status.update(left=_status_left(session_title, session_id), center=server_id or "emrg") + status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg") term.set_title(f"{session_title} @ {project_name}") term.render() continue @@ -1067,6 +1073,7 @@ def _handle_selector_nav(data: bytes, widget) -> bool: async def handle_key(data: bytes) -> bool: nonlocal inp, status, history, paste_mode, stream_buffer, conn, chat, busy, need_new_assistant, session_id, session_title, msg_count, cwd + nonlocal current_model nonlocal session_sel, delete_sel, project_sel, model_sel, rewind_sel, task_sel nonlocal history_index, history_saved_input nonlocal _autocomplete_active, _autocomplete_widget @@ -1842,7 +1849,7 @@ def _is_image_token(s, i): history.append(text); stream_buffer = "" history_index = -1 # reset history navigation on submit chat.add("assistant", "") - msg_count += 1; _update_right() + msg_count += 1; _update_left_extra() logger.debug("ROWS after asst: %d [%s]", len(chat.rows), ', '.join(f'{r.role}={r.content[:20]}' for r in chat.rows if isinstance(r, ChatRow))) _last_center = "thinking..." diff --git a/emrg/client/python_tui/output.py b/emrg/client/python_tui/output.py index e905ca4c..f88207eb 100644 --- a/emrg/client/python_tui/output.py +++ b/emrg/client/python_tui/output.py @@ -254,24 +254,15 @@ def write_frame( last_style_id = -1 # force transition on first diff cell last_hyperlink_id = -1 has_output = False - row_dirty_end: dict[int, int] = {} # y → max x changed in that row for x, y, prev, curr in diffs: - # When a WIDE character is at position x, the SPACER_TAIL at x+1 - # must be protected from the trailing CLEAR_TO_EOL cleanup. - # If row_dirty_end stops at x, the cleanup CUP+EL would target the - # SPACER_TAIL cell and erase it, breaking the wide character on screen. - pw = int(getattr(prev, "width", 0)) - cw = int(getattr(curr, "width", 0)) - dirty_end = x + 1 if (pw == 1 or cw == 1) else x - row_dirty_end[y] = max(row_dirty_end.get(y, 0), dirty_end) - # SPACER_TAIL detection: wide chars occupy 2 cells (WIDE + SPACER_TAIL). # The WIDE cell already advanced the terminal 2 columns, so the # SPACER_TAIL cell must not reposition the cursor, write a character, # or emit a style transition — it represents the terminal cursor's # implicit position, not a cell that needs painting. curr_char = getattr(curr, "char", " ") + cw = int(getattr(curr, "width", 0)) is_spacer = cw == 2 # Cursor positioning @@ -308,18 +299,14 @@ def write_frame( if not is_spacer: parts.append(curr_char if curr_char else " ") has_output = True - - # Reset style and clear to end of each affected row - # This eliminates wide-character ghost artifacts (spacer tails, orphan cursors) - if row_dirty_end: - if last_style_id > 0: - parts.append("\x1b[0m") - last_style_id = 0 - for y in sorted(row_dirty_end.keys(), reverse=True): - last_col = row_dirty_end[y] - # Move to one past the last changed cell, erase to end of line - parts.append(f"\x1b[{y + 1};{last_col + 2}H") - parts.append(CLEAR_TO_EOL) + # SPACER_TAIL cells need no output: a wide char advances the terminal + # cursor 2 columns, and its 2nd column inherently covers any stale glyph + # from the previous frame. Wide-char removal is handled by the + # inline-shrink path (prev char → curr empty) which writes a space + # through the normal branch above (rant 2026-08-11T19:59:09 / review + # 2026-08-11: an explicit space here would land one column PAST the + # spacer — the cursor is at x+2 after the WIDE cell, not x+1 — shifting + # chars after CJK insertions). if sync: parts.append(CURSOR_SHOW) diff --git a/emrg/client/python_tui/widgets/status_line.py b/emrg/client/python_tui/widgets/status_line.py index 1425ed6c..2216f98e 100644 --- a/emrg/client/python_tui/widgets/status_line.py +++ b/emrg/client/python_tui/widgets/status_line.py @@ -1,7 +1,9 @@ """Status line widget — single-line footer bar. -Displays token usage, model name, agent state, and other status info. -Follows Codex's StatusLineWidget pattern: left/center/right sections. +Displays model name, agent state, and other status info. +Layout (rant 2026-08-11T20:02:43): left = session + model + elapsed + +message count + dir (all core info), center = server id + host only. +No right section. """ from __future__ import annotations @@ -10,39 +12,57 @@ class StatusLine(Widget): - """Single-line status footer with three sections. + """Single-line status footer with two sections. Args: - left: Left-aligned content (e.g., agent name). - center: Center-aligned content (e.g., model name). - right: Right-aligned content (e.g., token count). - model: Optional model display name. - tokens: Optional token usage count. + left: Left-aligned content (session title + short id + model). + center: Center-aligned content (server id + host). + model: Optional model display name (center fallback). + left_elapsed: Optional elapsed-time string (e.g. ``[1:23]``) appended + to the left section while busy. + left_extra: Optional extra left content (e.g. ``· 3 msgs · ~/proj``). """ def __init__( self, left: str = "", center: str = "", - right: str = "", model: str | None = None, - tokens: int | None = None, + left_elapsed: str = "", + left_extra: str = "", ) -> None: self.left = left self.center = center - self.right = right self._model = model - self._tokens = tokens - self._elapsed: str = "" + self._left_elapsed = left_elapsed + self._left_extra = left_extra self._dirty = True @property def elapsed(self) -> str: - return self._elapsed + return self._left_elapsed @elapsed.setter def elapsed(self, value: str) -> None: - self._elapsed = value + self._left_elapsed = value + self._dirty = True + + @property + def left_elapsed(self) -> str: + return self._left_elapsed + + @left_elapsed.setter + def left_elapsed(self, value: str) -> None: + self._left_elapsed = value + self._dirty = True + + @property + def left_extra(self) -> str: + return self._left_extra + + @left_extra.setter + def left_extra(self, value: str) -> None: + self._left_extra = value self._dirty = True @property @@ -62,54 +82,44 @@ def model(self, value: str | None) -> None: self._model = value self._dirty = True - @property - def tokens(self) -> int | None: - return self._tokens - - @tokens.setter - def tokens(self, value: int | None) -> None: - self._tokens = value - self._dirty = True - def update( self, left: str | None = None, center: str | None = None, - right: str | None = None, model: str | None = None, - tokens: int | None = None, + left_elapsed: str | None = None, + left_extra: str | None = None, ) -> None: """Update any fields and mark dirty.""" if left is not None: self.left = left if center is not None: self.center = center - if right is not None: - self.right = right if model is not None: self._model = model - if tokens is not None: - self._tokens = tokens + if left_elapsed is not None: + self._left_elapsed = left_elapsed + if left_extra is not None: + self._left_extra = left_extra self._dirty = True def render(self, ctx: RenderContext) -> list[Line]: - """Render a single-line status bar: [left] [center] [right].""" - # Build text sections - right_text = self.right - if self._tokens is not None: - right_text = f"↑ {self._tokens:,} tk {right_text}" + """Render a single-line status bar: [left] [center].""" + # Build left section: session/model/elapsed/msg-count/dir + left_parts: list[str] = [] + if self.left: + left_parts.append(self.left) + if self._left_elapsed: + left_parts.append(self._left_elapsed) + if self._left_extra: + left_parts.append(self._left_extra) + left_text = (" " + " ".join(left_parts)) if left_parts else "" - left_text = f" {self.left}" if self.left else "" center_text = self.center or self._model or "" - if center_text and self._elapsed: - center_text = f"{center_text} {self._elapsed}" - elif self._elapsed: - center_text = self._elapsed - # Layout: left fixed → center fills remaining → right fixed, right-aligned + # Layout: left fixed → center fills remaining width = ctx.width - fixed_width = len(left_text) + len(right_text) - available_center = max(0, width - fixed_width) + available_center = max(0, width - len(left_text)) if center_text and available_center > 0: center_text = center_text.center(available_center) @@ -119,10 +129,6 @@ def render(self, ctx: RenderContext) -> list[Line]: spans.append(Span(text=left_text, style="bold magenta")) if center_text: spans.append(Span(text=center_text, style="dim")) - if right_text: - # Pad left side of right section to push it to the right edge - right_pad = max(0, width - len(left_text) - len(center_text) - len(right_text)) - spans.append(Span(text=f"{' ' * right_pad}{right_text}", style="dim")) self._dirty = False return [Line(spans=spans, style=ctx.style)] diff --git a/tests/test_buffer.py b/tests/test_buffer.py index 13efac12..d40df1a6 100644 --- a/tests/test_buffer.py +++ b/tests/test_buffer.py @@ -10,7 +10,14 @@ from rich.style import Style -from emrg.client.python_tui.buffer import Buffer, Cell, CellWidth, write_lines_to_buffer +from emrg.client.python_tui.buffer import ( + Buffer, + Cell, + CellWidth, + diff_buffers, + write_lines_to_buffer, +) +from emrg.client.python_tui.output import write_frame from emrg.client.python_tui.widgets.base import Line, Span @@ -236,3 +243,27 @@ def test_newlines_skipped(): assert _cell_char(buf, 0, 0) == "a" assert _cell_char(buf, 1, 0) == "b" assert _cell_char(buf, 2, 0) == " " # no newline rendered + + +# ── Wide-char ghost cleanup (rant 2026-08-11T19:59:09) ───────── + + +def test_wide_char_removal_ghost_covered_without_clear_to_eol(): + """Removing a wide char covers the old SPACER_TAIL ghost with a space. + + Regression for the CLEAR_TO_EOL bug: the old row-cleanup erased unchanged + cells right of the last changed cell (chars vanished on cursor left-move). + The diff engine itself emits empty-cell updates, so write_frame must write + a space (not clear to EOL) and never emit ``\\x1b[0K``. + """ + buf1 = Buffer(width=8, height=1) + write_lines_to_buffer(buf1, [make_line([Span(text="a你b")])]) + buf2 = Buffer(width=8, height=1) + write_lines_to_buffer(buf2, [make_line([Span(text="ab")])]) + + diffs = diff_buffers(buf1, buf2) + out = write_frame(diffs, style_pool=buf1.style_pool) + + assert "\x1b[0K" not in out # no CLEAR_TO_EOL + assert " " in out # emptied cells overwritten with spaces + assert "b" in out # the surviving char is rewritten diff --git a/tests/test_output.py b/tests/test_output.py index 2196b22a..7d7e4bfe 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -9,7 +9,8 @@ from rich.style import Style -from emrg.client.python_tui.output import style_diff_sgr, style_to_sgr +from emrg.client.python_tui.buffer import Cell, CellWidth, StylePool +from emrg.client.python_tui.output import style_diff_sgr, style_to_sgr, write_frame # ── style_to_sgr ────────────────────────────────────────────── @@ -177,3 +178,77 @@ def test_diff_empty_string_on_equivalent_styles(): b = Style(bold=True, color="red") assert a is not b # different objects assert style_diff_sgr(a, b) == "" # but semantically equal → no transition + + +# ── write_frame row cleanup (rant 2026-08-11T19:59:09) ───────── + + +def _pool(): + """StylePool with plain + reverse interned.""" + pool = StylePool() + pool.intern(Style()) # id 0 = plain + pool.intern(Style(reverse=True)) # id 1 = reverse cursor + return pool + + +def test_write_frame_cursor_left_no_clear_to_eol(): + """Cursor left-move diff must NOT emit CLEAR_TO_EOL. + + "hello world" cursor moving left flips the cursor cell's reverse style + back to plain. The old row-cleanup then emitted CUP+EL from the last + changed cell, erasing the unchanged "world" to the right. The fix removes + that cleanup entirely — output must rewrite the changed cells only. + """ + pool = _pool() + diffs = [ + # cell 6: 'o' loses reverse (old cursor position) + (6, 0, + Cell(char="o", style_id=1, width=CellWidth.NARROW), + Cell(char="o", style_id=0, width=CellWidth.NARROW)), + # cell 7: ' ' loses reverse + (7, 0, + Cell(char=" ", style_id=1, width=CellWidth.NARROW), + Cell(char=" ", style_id=0, width=CellWidth.NARROW)), + ] + out = write_frame(diffs, style_pool=pool) + assert "\x1b[0K" not in out # no CLEAR_TO_EOL → chars right of cursor survive + assert "o" in out # the character is rewritten + + +def test_write_frame_spacer_tail_overwrites_stale_char(): + """SPACER_TAIL over a stale ASCII char needs no explicit space. + + "hello world" → "hello 世": cell 7 was 'o', now holds the SPACER_TAIL of + the wide char 世. The WIDE glyph advances the terminal cursor 2 columns + and its 2nd column inherently covers the stale 'o' — writing an extra + space would land one column PAST the spacer (cursor is at x+2, not x+1) + and shift subsequent characters (review 2026-08-11, off-by-one). + """ + pool = _pool() + diffs = [ + (6, 0, + Cell(char="o", style_id=0, width=CellWidth.NARROW), + Cell(char="世", style_id=0, width=CellWidth.WIDE)), + (7, 0, + Cell(char="o", style_id=0, width=CellWidth.NARROW), + Cell(char="", style_id=0, width=CellWidth.SPACER_TAIL)), + ] + out = write_frame(diffs, style_pool=pool) + assert "\x1b[0K" not in out + assert out == "\x1b[1;7H世" # wide glyph alone covers the stale 'o' + + +def test_write_frame_spacer_tail_empty_prev_emits_nothing(): + """SPACER_TAIL diff with empty prev must emit nothing. + + A pure style change on a spacer cell (or an untouched spacer) has no + visible effect — no cursor motion, no character, no SGR output. + """ + pool = _pool() + diffs = [ + (7, 0, + Cell(char="", style_id=0, width=CellWidth.SPACER_TAIL), + Cell(char="", style_id=1, width=CellWidth.SPACER_TAIL)), + ] + out = write_frame(diffs, style_pool=pool) + assert out == "" diff --git a/tests/test_status_line.py b/tests/test_status_line.py new file mode 100644 index 00000000..43335179 --- /dev/null +++ b/tests/test_status_line.py @@ -0,0 +1,69 @@ +"""Unit tests for the TUI status line widget (rant 2026-08-11T20:02:43). + +Layout after the reorg: left = session + model + elapsed + msg-count + dir +(bold magenta), center = server id + host only (dim), no right section. +""" + +from __future__ import annotations + +from emrg.client.python_tui.widgets.base import RenderContext +from emrg.client.python_tui.widgets.status_line import StatusLine + + +def _spans(status: StatusLine, width: int = 100) -> list[dict]: + lines = status.render(RenderContext(width=width)) + assert len(lines) == 1 + return [{"text": s.text, "style": str(s.style)} for s in lines[0].spans] + + +def test_left_contains_session_model_elapsed_extra(): + """All core info lands in the left (bold magenta) section.""" + status = StatusLine( + left="emrg-main (s_260727) [deepseek-v4-flash]", + center="emrg-5fa @ host", + ) + status.elapsed = "[1:23]" + status.left_extra = "· 3 msgs · ~/proj" + spans = _spans(status) + + left = spans[0] + assert left["text"] == " emrg-main (s_260727) [deepseek-v4-flash] [1:23] · 3 msgs · ~/proj" + assert "bold magenta" in left["style"] + + # Center is the server id + host only + assert spans[1]["text"].strip() == "emrg-5fa @ host" + assert "dim" in spans[1]["style"] + + +def test_no_right_section(): + """No right section is rendered after the reorg.""" + status = StatusLine(left="emrg (sid12345) [m]", center="ab12 @ h") + spans = _spans(status) + # Only two spans max: left + center + assert len(spans) <= 2 + texts = "".join(s["text"] for s in spans) + assert "tk" not in texts # old token counter removed + assert not any(s["text"].endswith("msgs") or "msgs" in s["text"].split(" ") and s is spans[-1] for s in spans) + + +def test_elapsed_updates_dirty_and_renders_left(): + """Setting elapsed marks dirty and appends to left, not center.""" + status = StatusLine(left="emrg (sid)", center="ab @ h") + assert status.dirty is True + status.render(RenderContext(width=80)) + assert status.dirty is False + + status.elapsed = "[0:45]" + assert status.dirty is True + spans = _spans(status) + assert "[0:45]" in spans[0]["text"] + assert "[0:45]" not in spans[1]["text"] # not in center + + +def test_elapsed_and_extra_empty_when_idle(): + """Idle state: no elapsed, left shows session + hint only.""" + status = StatusLine(left="emrg (sid) [model]", center="ab @ h") + status.left_extra = "Enter=send Esc=quit /help" + spans = _spans(status) + assert "[0" not in spans[0]["text"] + assert "Enter=send" in spans[0]["text"]