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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
27 changes: 27 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
<title>EMRG</title>
<link rel="stylesheet" href="css/tokens.css" />
<link rel="stylesheet" href="css/base.css" />
Expand Down
69 changes: 64 additions & 5 deletions emrg/gui/renderer/js/result-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
Expand All @@ -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));
Expand All @@ -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;
Expand Down
58 changes: 54 additions & 4 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1169,24 +1169,74 @@ 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 不应激活");
});

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");
assert.ok(empty.length >= 1, "二进制应显示提示而非文本");
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: "<span class=\"hljs-keyword\">def</span> 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();
Expand Down
Loading