From aabda9f34c28bd0bea4c2c913c4bb1f4aba92f05 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 14 Aug 2026 11:59:49 +0800 Subject: [PATCH 1/2] emrg: clear terminal residue on width shrink (rant 2026-08-14T11:47:11) --- Agent.md | 2 +- emrg/client/python_tui/terminal.py | 15 ++++++ tests/test_terminal_render.py | 80 ++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/test_terminal_render.py diff --git a/Agent.md b/Agent.md index a8caef3f..6a0b3b75 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (801) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (804) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (244: 44 daemon_client + 19 conn-manager + 22 app-commands + 119 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/client/python_tui/terminal.py b/emrg/client/python_tui/terminal.py index 37720b45..47fdb410 100644 --- a/emrg/client/python_tui/terminal.py +++ b/emrg/client/python_tui/terminal.py @@ -23,6 +23,7 @@ from emrg.client.python_tui.buffer import Buffer, CharPool, HyperlinkPool, StylePool, diff_buffers from emrg.client.python_tui.output import ( + CLEAR_SCREEN, CURSOR_HIDE, CURSOR_SHOW, CURSOR_HOME, @@ -142,6 +143,10 @@ def __post_init__(self) -> None: ) self._rendered_cache: dict[str, list[object]] = {} self._scrollback_lines_pushed: int = 0 + # 上次渲染宽度(rant 2026-08-14T11:47:11):窗口缩窄时终端屏幕每行右侧的旧字符 + # 不会被 diff 清除(diff_buffers 只比较 min(prev_w, curr_w) 列,且 Buffer.resize + # 缩窄已物理截断旧宽度信息)→ render 检测到缩窄时先 CLEAR_SCREEN 再全量重绘。 + self._last_render_width: int = self.caps.width @property def viewport_height(self) -> int: @@ -215,6 +220,16 @@ def render(self, full: bool = False) -> None: width = self.viewport.viewport_width ctx = RenderContext(width=width) + # 宽度缩窄 → 终端屏幕右侧残留旧字符(diff 只覆盖新宽度;Buffer.resize 缩窄已 + # 丢弃旧宽度信息,diff_buffers 无从清除)。先清屏 + 清 front buffer 强制全量 + # 重绘,杜绝残留叠加(rant 2026-08-14T11:47:11)。 + if width < self._last_render_width: + sys.stdout.write(CLEAR_SCREEN) + if self._front_buffer: + self._front_buffer.clear() + full = True + self._last_render_width = width + # Cache last rendered output per widget so dirty=False doesn't blank it def _get_lines(name: str) -> list[object]: diff --git a/tests/test_terminal_render.py b/tests/test_terminal_render.py new file mode 100644 index 00000000..b7171c4e --- /dev/null +++ b/tests/test_terminal_render.py @@ -0,0 +1,80 @@ +"""Unit tests for Terminal render — 宽度缩窄残留清除(rant 2026-08-14T11:47:11)。 + +窗口缩窄时终端屏幕每行右侧的旧字符不会被 diff 清除(diff_buffers 只比较 +min(prev_w, curr_w) 列,且 Buffer.resize 缩窄已物理截断旧宽度信息)→ +render 检测到缩窄时先 CLEAR_SCREEN 再全量重绘。本测试验证该行为。 +""" + +from __future__ import annotations + +import io +import sys +from contextlib import redirect_stdout + +import pytest + +# 依赖 posix 路径(raw mode / termios 守卫),Windows 跳过(同 test_app_widgets) +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Terminal 渲染测试依赖 POSIX 路径") + +from emrg.client.python_tui.output import CLEAR_SCREEN +from emrg.client.python_tui.terminal import Terminal +from emrg.client.python_tui.widgets.base import Line, RenderContext, Span + + +class FakeWidget: + """Minimal widget: renders `width` chars per line.""" + + def __init__(self) -> None: + self.dirty = True + + def render(self, ctx: RenderContext) -> list[Line]: + return [Line(spans=[Span(text="x" * ctx.width)])] + + +def _set_width(term: Terminal, width: int) -> None: + """Force terminal width (SIGWINCH path would probe real tty — not usable in tests).""" + term.caps.width = width + term.viewport.resize(term.caps.height, width) + + +def _render(term: Terminal) -> str: + buf = io.StringIO() + with redirect_stdout(buf): + term.render(full=True) + return buf.getvalue() + + +def test_render_shrink_emits_clear_screen(): + """缩窄 40 → 20:输出必须先 CLEAR_SCREEN(右侧残留清除)。""" + term = Terminal() + term.mount(chat=FakeWidget()) + _set_width(term, 40) + _render(term) # 建立 _last_render_width = 40 + + _set_width(term, 20) + out = _render(term) + + assert CLEAR_SCREEN in out + + +def test_render_same_width_no_clear_screen(): + """同宽两次渲染:无 CLEAR_SCREEN(避免无谓闪烁)。""" + term = Terminal() + term.mount(chat=FakeWidget()) + _set_width(term, 40) + _render(term) + + out = _render(term) + assert CLEAR_SCREEN not in out + + +def test_render_grow_no_clear_screen(): + """拉宽 40 → 60:无 CLEAR_SCREEN(新增列由 diff 覆盖,无需清屏)。""" + term = Terminal() + term.mount(chat=FakeWidget()) + _set_width(term, 40) + _render(term) + + _set_width(term, 60) + out = _render(term) + assert CLEAR_SCREEN not in out From 99fd8c33db1fe29d7d0d949e56eea8c68b098654 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 14 Aug 2026 12:05:51 +0800 Subject: [PATCH 2/2] emrg: GUI open-sessions sidebar shows title for cross-project sessions (rant 2026-08-14T12:01:44) --- Agent.md | 4 ++-- emrg/gui/main.js | 25 ++++++++++++++++++++++--- emrg/gui/renderer/js/sidebar.js | 2 +- emrg/gui/test/renderer.smoke.test.js | 24 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Agent.md b/Agent.md index 6a0b3b75..95d5db78 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (244: 44 daemon_client + 19 conn-manager + 22 app-commands + 119 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (245: 44 daemon_client + 19 conn-manager + 22 app-commands + 120 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (804) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (244: 44 daemon_client + 19 conn-manager + 22 app-commands + 119 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (245: 44 daemon_client + 19 conn-manager + 22 app-commands + 120 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index c73c1e1d..d64e6629 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -375,6 +375,12 @@ vision = false const clean = String(title || "").trim().slice(0, 80); // 截断超长标题 if (!clean) throw new Error("empty title"); const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: projectDir, title: clean }, 5000); + // 跨项目会话重命名成功后立即同步侧边栏标题(rant 12:01:44) + const v = openSessions.get(sessionId); + if (v) { + v.title = frame.title || clean; + broadcastOpenSessions(); + } return { ok: true, title: frame.title || clean }; }); @@ -978,6 +984,19 @@ vision = false lastActive: new Date().toISOString(), }); broadcastOpenSessions(); + // 跨项目标题(rant 12:01:44):异步拉该会话所在项目列表取 title—— + // 找到则 v.title = s.title 并广播(失败/无 title → 保持 undefined → 侧边栏 sid 兜底) + listSessions(projectPath) + .then((sessions) => { + const v = openSessions.get(sid); + if (!v) return; // 会话已关闭/移除 + const s = sessions.find((x) => x.session_id === sid); + if (s && s.title) { + v.title = s.title; + broadcastOpenSessions(); + } + }) + .catch(() => { /* 拉取失败保持 undefined → sid 兜底 */ }); } // 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘 @@ -1084,7 +1103,7 @@ vision = false function openSessionsList() { return [...openSessions.entries()] - .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive })) + .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive, title: v.title })) .sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); } @@ -1163,9 +1182,9 @@ vision = false }); } - async function listSessions() { + async function listSessions(cwd = projectDir) { try { - const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd: projectDir }, 5000); + const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd }, 5000); return frame.sessions || []; } catch (e) { logger.warn(`[gui] list_sessions failed: ${e.message}`); diff --git a/emrg/gui/renderer/js/sidebar.js b/emrg/gui/renderer/js/sidebar.js index 595416c4..9c6b4525 100644 --- a/emrg/gui/renderer/js/sidebar.js +++ b/emrg/gui/renderer/js/sidebar.js @@ -27,7 +27,7 @@ const Sidebar = (() => { const known = (App.state && App.state.sessions) || []; for (const entry of openSessions) { const cur = known.find((s) => s.session_id === entry.sid) || {}; - const title = cur.title || entry.sid; // G27:title 优先 + const title = entry.title || cur.title || entry.sid; // entry.title 优先(跨项目),再 cur.title(当前项目),最后 sid const item = el("div", { class: "conv-item open-session-item" }); item.dataset.sid = entry.sid; item.appendChild(el("span", { class: "conv-title" }, _t("sidebar.openSessionOf", { project: entry.projectName || "", title }))); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index ab1b9d2b..e65c0c1e 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1913,6 +1913,30 @@ test("P4 s2: open_sessions 事件 → 渲染打开会话区(项目名/标题 + assert.strictEqual(els["open-sessions-label"].hidden, true, "label hidden when no open sessions"); }); +test("P4 s2: 跨项目打开会话(entry.title 优先,state.sessions 无该 sid)→ 显示 entry.title 而非 sid", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-x";' + + 'App.state.sessions = [{ session_id: "sess-local", title: "Local" }];' + // 当前项目会话;无 sess-x / sess-other + 'App.handleEvent({ type: "open_sessions", data: { openSessions: [' + // main 已按 lastActive 倒序 + ' { sid: "sess-x", projectName: "evolution", projectPath: "/p/evolution", lastActive: "t3", title: "Evolution Task" },' + // 跨项目 + title + ' { sid: "sess-other", projectName: "mem", projectPath: "/p/mem", lastActive: "t2" },' + // 跨项目无 title → sid 兜底 + ' { sid: "sess-local", projectName: "emrg", projectPath: "/p/emrg", lastActive: "t1" }' + // 当前项目 → state.sessions title + '] } });', + ctx + ); + const nav = els["open-sessions"]; + assert.strictEqual(nav.children.length, 3, "three open-session items rendered"); + const t0 = nav.children[0].children[0] || nav.children[0]; + assert.ok((t0.textContent || "").includes("Evolution Task"), "cross-project entry shows entry.title"); + assert.ok(!(t0.textContent || "").includes("sess-x"), "does NOT fall back to sid when entry.title present"); + const t1 = nav.children[1].children[0] || nav.children[1]; + assert.ok((t1.textContent || "").includes("sess-other"), "cross-project no title + not in state.sessions → sid fallback"); + const t2 = nav.children[2].children[0] || nav.children[2]; + assert.ok((t2.textContent || "").includes("Local"), "current-project entry still resolves via state.sessions title"); +}); + test("P4 s2: closeOpenSession 关闭激活会话 → 切到剩余打开会话 + 容器释放", async () => { let closed = null; const { ctx, els } = makeSandbox({