diff --git a/Agent.md b/Agent.md
index f181531..4e043ea 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` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
+ - Unit tests `npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions
- **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles
- **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope)
@@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```
Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"`
-GUI: `cd emrg/gui && npm test` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 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`
+GUI: `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 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/README.cn.md b/README.cn.md
index b40829e..d8e8168 100644
--- a/README.cn.md
+++ b/README.cn.md
@@ -147,7 +147,7 @@ EMRG 不只是追赶——它自己追上来。
贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。
-快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(202 项:43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
+快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(205 项:43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
---
diff --git a/README.md b/README.md
index d6385fd..60402ae 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve
Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md).
-Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
+Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)
---
diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css
index a3ca71e..b321215 100644
--- a/emrg/gui/renderer/css/layout.css
+++ b/emrg/gui/renderer/css/layout.css
@@ -550,6 +550,33 @@ body.sidebar-collapsed #app {
white-space: pre;
color: var(--text-1);
}
+/* P3.3:查看器图片直显 + md 渲染 */
+.viewer-img {
+ flex: 1;
+ object-fit: contain;
+ max-width: 100%;
+ padding: var(--sp-3);
+ box-sizing: border-box;
+}
+.viewer-md {
+ flex: 1;
+ overflow: auto;
+ padding: var(--sp-3);
+ font-size: var(--fs-secondary);
+ line-height: 1.6;
+ color: var(--text-1);
+}
+.viewer-md h1, .viewer-md h2, .viewer-md h3 { margin: var(--sp-2) 0; }
+.viewer-md code {
+ background: var(--bg-soft);
+ border-radius: 4px;
+ padding: 1px 4px;
+}
+.viewer-md pre code {
+ display: block;
+ padding: var(--sp-3);
+ overflow-x: auto;
+}
/* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */
.artifact-row {
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index 2043db4..11b0604 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -2,7 +2,7 @@
-
+
EMRG
diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js
index 81a93dc..65b16cd 100644
--- a/emrg/gui/renderer/js/result-panel.js
+++ b/emrg/gui/renderer/js/result-panel.js
@@ -201,14 +201,25 @@ const ResultPanel = (() => {
if ((key || null) === currentSid) { renderTabbar(); activateTab(st.active, key); }
}
- // ── 文件查看器(P3.3 基础版:readFile 文本 / 二进制提示 / 系统工具打开) ──
+ // ── 文件查看器(P3.3:文本高亮 / md 渲染 / 图片直显 / 二进制提示) ──
+ const IMAGE_EXT = /\.(png|jpe?g|gif|svg|webp|bmp|ico)$/i;
+
+ function isImagePath(path) {
+ return IMAGE_EXT.test(String(path).split(/[?#]/)[0]);
+ }
+ function isMarkdownPath(path) {
+ return /\.(md|markdown|mdown)$/i.test(String(path));
+ }
+
async function loadFileTab(path) {
const vp = viewerEl();
if (!vp) return;
const st = stateFor(currentSid);
const tab = st.tabs.find((t) => t.path === path);
if (!tab || tab.loading) return;
- if (tab.content !== undefined) { renderViewer(vp, tab); return; }
+ if (tab.content !== undefined || tab.image || tab.readError) { await renderViewer(vp, tab); return; }
+ // 图片不走 read_file(file:// 直显,P3.3)
+ if (isImagePath(path)) { tab.image = true; await renderViewer(vp, tab); return; }
tab.loading = true;
vp.innerHTML = "";
vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerLoading")));
@@ -225,10 +236,10 @@ const ResultPanel = (() => {
} finally {
tab.loading = false;
}
- renderViewer(vp, tab);
+ await renderViewer(vp, tab);
}
- function renderViewer(vp, tab) {
+ async function renderViewer(vp, tab) {
vp.innerHTML = "";
const head = el("div", { class: "viewer-head" });
head.appendChild(el("span", { class: "viewer-path" }, tab.path));
@@ -246,11 +257,59 @@ const ResultPanel = (() => {
vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerBinary")));
return;
}
+ if (tab.image) {
+ const img = el("img", { class: "viewer-img", src: "file://" + tab.path, alt: tab.name || "" });
+ img.addEventListener("error", () => {
+ img.style.display = "none";
+ const hint = el("div", { class: "result-empty" }, _t("result.viewerError"));
+ vp.appendChild(hint);
+ });
+ vp.appendChild(img);
+ return;
+ }
+ // md → markdown 渲染(DOMPurify sanitize,与聊天区同源)
+ if (isMarkdownPath(tab.path)) {
+ try {
+ const html = await window.emrgMarkdown.renderMarkdown(tab.content);
+ const mdBox = el("div", { class: "viewer-md" });
+ mdBox.innerHTML = html;
+ vp.appendChild(mdBox);
+ return;
+ } catch { /* fall through to plain text */ }
+ }
+ // 文本:hljs 高亮(精确 lang,无 lang highlightAuto 兜底——与 markdown.js code renderer 同策略)
const pre = el("pre", { class: "viewer-pre" });
- pre.appendChild(el("code", {}, tab.content));
+ const code = el("code", {}, tab.content);
+ try {
+ const hljs = window.hljs;
+ if (hljs) {
+ const lang = detectLang(tab.path);
+ if (lang && hljs.getLanguage(lang)) {
+ code.innerHTML = hljs.highlight(tab.content, { language: lang, ignoreIllegals: true }).value;
+ } else {
+ code.innerHTML = hljs.highlightAuto(tab.content).value;
+ }
+ code.classList.add("hljs", "language-" + (lang || "plaintext"));
+ }
+ } catch { /* highlight failure → fall back to escaped text */ }
+ pre.appendChild(code);
vp.appendChild(pre);
}
+ /** 按文件扩展名推断语言(对齐 highlight.custom.js 已注册语言) */
+ function detectLang(path) {
+ const ext = String(path).split(".").pop().toLowerCase();
+ const map = {
+ py: "python", js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript",
+ sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", htm: "html",
+ json: "json", yml: "yaml", yaml: "yaml", toml: "ini", md: "markdown",
+ go: "go", rs: "rust", java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp",
+ sql: "sql", xml: "xml", ini: "ini", dockerfile: "dockerfile", diff: "diff",
+ };
+ if (path.toLowerCase().includes("dockerfile")) return "dockerfile";
+ return map[ext] || "";
+ }
+
/** 切换会话 → Tab/产物状态按 sid 隔离(缺口 5) */
function switchSession(sid) {
currentSid = sid || null;
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index e8d217d..725957c 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -28,7 +28,7 @@ function makeEl(id) {
classList: {
// className 为唯一事实源:el() 直接赋值 className 后 classList 操作不得清空既有类
_set() { node._cls = new Set((node.className || "").split(/\s+/).filter(Boolean)); },
- add(c) { node.classList._set(); node._cls.add(c); node._update(); },
+ add(...cs) { node.classList._set(); for (const c of cs) node._cls.add(c); node._update(); },
remove(c) { node.classList._set(); node._cls.delete(c); node._update(); },
// 忠实 DOM:toggle(c, force)——force 指定时按 force 加/删(sidebar highlight 依赖)
toggle(c, force) {
@@ -1169,9 +1169,9 @@ test("P3:点击文件行 → 打开文件 Tab + 查看器加载内容", async
await tick(); // openFileTab → activateTab → loadFileTab(async readFile)
// 文件 Tab 打开
assert.ok([...els["result-tabbar"].children].some((c) => c.dataset.path === "/proj/README.md"), "文件 Tab 应打开");
- // 查看器 pane 激活 + 文本渲染
+ // 查看器 pane 激活 + 内容渲染(README.md → P3.3 markdown 渲染)
assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活");
- assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length >= 1, "应渲染文本内容");
+ assert.ok(els["result-viewer"].querySelectorAll(".viewer-md").length >= 1, "md 文件应渲染 markdown 内容");
// 静态 Tab 不被误激活
assert.ok(!els["result-tab-files"].classList.contains("active"), "文件 Tab 不应激活");
});
@@ -1179,7 +1179,7 @@ test("P3:点击文件行 → 打开文件 Tab + 查看器加载内容", async
test("P3:查看器二进制文件 → 提示用系统工具打开", async () => {
const { ctx, els } = makeSandbox({ readFile: async () => ({ content: "", binary: true }) });
await tick();
- await vm.runInContext('ResultPanel.openFileTab(null, "/img/logo.png")', ctx);
+ await vm.runInContext('ResultPanel.openFileTab(null, "/data/blob.bin")', ctx); // 非图片扩展名(.bin)
await tick();
assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活");
const empty = els["result-viewer"].querySelectorAll(".result-empty");
@@ -1187,6 +1187,56 @@ test("P3:查看器二进制文件 → 提示用系统工具打开", async () =
assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length === 0, "二进制不渲染文本");
});
+test("P3.3:图片文件不走 read_file(file:// 直显 img)", async () => {
+ let readCalled = false;
+ const { ctx, els } = makeSandbox({
+ readFile: async () => { readCalled = true; return { content: "", binary: false }; },
+ });
+ await tick();
+ await vm.runInContext('ResultPanel.openFileTab(null, "/img/logo.png")', ctx);
+ await tick();
+ assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活");
+ assert.ok(els["result-viewer"].querySelectorAll(".viewer-img").length === 1, "应渲染 img 元素");
+ assert.strictEqual(readCalled, false, "图片不得调用 read_file");
+ // CSP 允许 file:(源级断言防回归)
+ const html = fs.readFileSync(path.join(__dirname, "..", "renderer", "index.html"), "utf8");
+ assert.ok(/img-src 'self' data: file:/.test(html), "CSP img-src 应含 file:");
+});
+
+test("P3.3:markdown 文件走 renderMarkdown 渲染", async () => {
+ const { ctx, els } = makeSandbox({
+ readFile: async () => ({ content: "# Title\n\nbody", binary: false }),
+ });
+ await tick();
+ await vm.runInContext('ResultPanel.openFileTab(null, "/proj/README.md")', ctx);
+ await tick();
+ assert.ok(els["result-viewer"].querySelectorAll(".viewer-md").length >= 1, "md 应渲染 viewer-md 容器");
+ assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length === 0, "md 不渲染纯文本 pre");
+});
+
+test("P3.3:文本代码文件 → hljs 高亮(.py → python)", async () => {
+ const sb = makeSandbox({
+ readFile: async () => ({ content: "def f():\n return 1", binary: false }),
+ });
+ const { ctx, els } = sb;
+ // hljs 是 window 级全局(overrides 只进 emrg)→ 沙箱创建后注入
+ sb.win.hljs = {
+ getLanguage: (l) => (l === "python" ? {} : null),
+ highlight: (code, opts) => ({ value: "def f():" }),
+ highlightAuto: (code) => ({ value: code }),
+ };
+ await tick();
+ await vm.runInContext('ResultPanel.openFileTab(null, "/proj/main.py")', ctx);
+ await tick();
+ const pre = els["result-viewer"].querySelectorAll(".viewer-pre");
+ assert.ok(pre.length === 1, "应渲染高亮 pre");
+ // harness querySelector 只支持类选择器 → 直接取 pre 子元素(code)
+ const code = pre[0].children && pre[0].children[0];
+ assert.ok(code, "pre 内应有 code");
+ assert.ok((code.className || "").includes("hljs"), "code 应带 hljs 类");
+ assert.ok((code.className || "").includes("language-python"), "应带 language-python 类");
+});
+
test("P3:切会话 → 文件树根跟随(per-session)", async () => {
const { ctx, els } = makeSandbox();
await tick();