diff --git a/Agent.md b/Agent.md index 49443817..508bf89d 100644 --- a/Agent.md +++ b/Agent.md @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1148) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (451: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 15 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (467: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` 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 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 0006a1ea..65e40b44 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -393,6 +393,44 @@ margin-top: var(--sp-2); } +/* ── Stage 2 mini 格式栏(rant 2026-08-28T14:07:29:粗体/斜体/删除线/代码/链接/列表/引用/标题) ── */ +.fmt-bar { + display: flex; + gap: 2px; + padding: 0 var(--sp-2) var(--sp-2); +} +.fmt-btn { + min-width: 28px; + height: 26px; + padding: 0 var(--sp-2); + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-input); + background: transparent; + color: var(--text-2); + font-size: var(--fs-secondary); + line-height: 1; + cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.fmt-btn:hover { + background: var(--bg-soft); + color: var(--text-1); +} +.fmt-btn.active { + background: var(--accent-soft); + color: var(--accent); +} +.fmt-label-bold { font-weight: 700; } +.fmt-label-italic { font-style: italic; } +.fmt-label-strike { text-decoration: line-through; } +.fmt-label-code { font-family: var(--font-mono, monospace); font-size: 12px; } +.fmt-label-heading { font-weight: 600; } + /* ── 断连横幅 ───────────────────────────── */ #conn-banner { position: absolute; diff --git a/emrg/gui/renderer/src/components/Composer.test.tsx b/emrg/gui/renderer/src/components/Composer.test.tsx index 78d5a9c8..ac6acbc2 100644 --- a/emrg/gui/renderer/src/components/Composer.test.tsx +++ b/emrg/gui/renderer/src/components/Composer.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Editor } from "@tiptap/react"; @@ -37,11 +37,11 @@ function setup( }); }; /** 在编辑器 DOM 上派发真实 keydown(tiptap editorProps.handleKeyDown 拦截) */ - const press = (key: string, mods: { shiftKey?: boolean; ctrlKey?: boolean } = {}) => { + const press = (key: string, mods: { shiftKey?: boolean; ctrlKey?: boolean } = {}, code?: string) => { act(() => { const dom = editorRef.current?.view.dom; if (!dom) throw new Error("editor not mounted"); - dom.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...mods })); + dom.dispatchEvent(new KeyboardEvent("keydown", { key, code, bubbles: true, cancelable: true, ...mods })); }); }; return { ...utils, editor, input, type, press }; @@ -308,3 +308,233 @@ describe("Composer — 发送流", () => { void version; }); }); + +describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () => { + /** 全选 + 取 markdown(tiptap-markdown 序列化断言) */ + function mdOf(s: ReturnType, editor: Editor): string { + const md = (editor.storage as unknown as { markdown: { getMarkdown(): string } }).markdown.getMarkdown(); + return md.trim(); + } + + it("格式栏渲染 9 个按钮(粗体/斜体/删除线/代码/链接/无序/有序/引用/标题)", async () => { + const store = createTranscriptStore(); + const s = setup(store); + await waitEditor(s); + expect(screen.getByTestId("fmt-bar")).toBeInTheDocument(); + const ids = ["bold", "italic", "strike", "code", "link", "bullet", "ordered", "quote", "heading"]; + for (const id of ids) expect(screen.getByTestId(`fmt-${id}`)).toBeInTheDocument(); + }); + + it("粗体按钮 → 选中文本序列化为 **bold**", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("hello bold"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-bold")); + expect(mdOf(s, editor)).toContain("**hello bold**"); + // 激活态高亮 + await waitFor(() => expect(screen.getByTestId("fmt-bold")).toHaveClass("active")); + }); + + it("斜体按钮 → *italic*", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("emph"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-italic")); + expect(mdOf(s, editor)).toContain("*emph*"); + }); + + it("删除线按钮 → ~~strike~~", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("obsolete"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-strike")); + expect(mdOf(s, editor)).toContain("~~obsolete~~"); + }); + + it("行内代码按钮 → `code`", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("const x"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-code")); + expect(mdOf(s, editor)).toContain("`const x`"); + }); + + it("链接按钮(window.prompt 输入 URL)→ [label](url);再次点击解除链接", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://example.com"); + act(() => { + editor.commands.insertContent("my link"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-link")); + expect(mdOf(s, editor)).toContain("[my link](https://example.com)"); + expect(promptSpy).toHaveBeenCalledTimes(1); + // 已激活链接 → 再点解除(不再弹 prompt) + await userEvent.click(screen.getByTestId("fmt-link")); + expect(mdOf(s, editor)).not.toContain("[my link]"); + expect(promptSpy).toHaveBeenCalledTimes(1); + promptSpy.mockRestore(); + }); + + it("无序列表按钮 → - item", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("todo"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-bullet")); + expect(mdOf(s, editor)).toContain("- todo"); + }); + + it("有序列表按钮 → 1. item", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("step"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-ordered")); + expect(mdOf(s, editor)).toContain("1. step"); + }); + + it("引用按钮 → > quote", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("noted"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-quote")); + expect(mdOf(s, editor)).toContain("> noted"); + }); + + it("标题按钮循环 p→h1→h2→h3→p(单按钮四态)", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("section"); + }); + // 每次点击前选中文本区间(0..7,"section" 长度)——selectAll 会把 toggleHeading + // 追加的尾随空段落也选上导致级别误读(tiptap v3 混合选区 isActive 不可靠) + const selectText = () => { + act(() => { + editor.commands.setTextSelection({ from: 0, to: 7 }); + }); + }; + const click = async () => { + selectText(); + await userEvent.click(screen.getByTestId("fmt-heading")); + }; + await click(); + expect(mdOf(s, editor)).toContain("# section"); + await click(); + expect(mdOf(s, editor)).toContain("## section"); + await click(); + expect(mdOf(s, editor)).toContain("### section"); + await click(); + expect(mdOf(s, editor)).toContain("section"); // 回到段落(无 # 前缀) + expect(mdOf(s, editor)).not.toMatch(/^#+ /); + }); + + it("⌘B 快捷键(StarterKit 内置)→ 粗体激活", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("shortcut"); + editor.commands.selectAll(); + }); + s.press("b", { ctrlKey: true }); // jsdom 无 metaKey → ctrlKey 等价 Mod + await waitFor(() => expect(editor.isActive("bold")).toBe(true)); + expect(mdOf(s, editor)).toContain("**shortcut**"); + }); + + it("⌘K 快捷键(无内置绑定,显式补挂)→ 链接 prompt", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://shortcut.dev"); + act(() => { + editor.commands.insertContent("kb"); + editor.commands.selectAll(); + }); + s.press("k", { ctrlKey: true }); + await waitFor(() => expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)")); + expect(promptSpy).toHaveBeenCalledTimes(1); + promptSpy.mockRestore(); + }); + + it("⌘⇧8 快捷键(无内置绑定)→ 无序列表", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("listy"); + editor.commands.selectAll(); + }); + s.press("8", { ctrlKey: true, shiftKey: true }, "Digit8"); + // isActive("bulletList") 在 tiptap v3 切换后选区不可靠 → 断言 markdown 输出 + await waitFor(() => expect(mdOf(s, editor)).toContain("- listy")); + }); + + it("⌘⇧7 快捷键(无内置绑定)→ 有序列表", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("seq"); + editor.commands.selectAll(); + }); + s.press("7", { ctrlKey: true, shiftKey: true }, "Digit7"); + await waitFor(() => expect(mdOf(s, editor)).toContain("1. seq")); + }); + + it("粘贴净化:transformPastedHTML 剥离 script/事件属性(DOMPurify)", async () => { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + const sanitize = editor.options.editorProps.transformPastedHTML as (html: string) => string; + const out = sanitize('oklnk'); + expect(out).toContain("ok"); + expect(out).toContain('href="https://a.dev"'); + expect(out).not.toContain(" { + const store = createTranscriptStore(); + const s = setup(store); + const editor = await waitEditor(s); + act(() => { + editor.commands.insertContent("keep selection"); + editor.commands.selectAll(); + }); + await userEvent.click(screen.getByTestId("fmt-italic")); + // 点击格式按钮后选区仍在(mousedown preventDefault 防失焦)→ 命令作用于选中文本 + expect(mdOf(s, editor)).toContain("*keep selection*"); + }); +}); diff --git a/emrg/gui/renderer/src/components/Composer.tsx b/emrg/gui/renderer/src/components/Composer.tsx index e0b43ac3..2cbc5c9b 100644 --- a/emrg/gui/renderer/src/components/Composer.tsx +++ b/emrg/gui/renderer/src/components/Composer.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState, type MutableRefObject } from "react"; -import { EditorContent, useEditor, type Editor } from "@tiptap/react"; +import { EditorContent, useEditor, useEditorState, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import { Markdown } from "tiptap-markdown"; +import DOMPurify from "../../../vendor/dompurify.min.js"; import { parseInput } from "../lib/commands"; import { CMD_MENU_CLOSED, @@ -26,7 +27,9 @@ import type { TranscriptStore } from "../lib/transcript"; * - 保留现有功能:Enter=发送 / Shift+Enter=换行(hardBreak)/ Ctrl+Enter=发送、 * / 补全菜单(editor.onUpdate + keydown 拦截)、高度自适应 ≤150px(CSS max-height)、 * G143 requestId 预生成、P2 busy 队列注入(#655)、失败恢复(setContent 回填 markdown)。 - * - Stage 2(后续 cycle):mini 格式栏 + 格式快捷键 + DOMPurify 粘贴净化。 + * - Stage 2(本 cycle):mini 格式栏(粗体/斜体/删除线/行内代码/链接/无序/有序/引用/标题) + * + 格式快捷键(⌘B/I/⇧S/E/K/⇧7/⇧8/⇧B/Alt 1-3——K/⇧7/⇧8 为显式补挂,其余 StarterKit 内置) + * + DOMPurify 粘贴净化(transformPastedHTML 拦截 HTML 粘贴路径,防恶意脚本)。 * - sendMessage 注入式(测试传假实现;默认 window.emrg.sendMessage)。 * - 类名与 vanilla 一致(composer-card / cmd-menu / cmd-menu-item / send-btn)。 */ @@ -63,6 +66,44 @@ export interface ComposerProps { const MAX_INPUT_HEIGHT = 150; +/** 格式命令(Stage 2):以 editor 为参的纯函数——editor 单例闭包经 fmtRef 桥接给按钮/快捷键 */ +function cycleHeading(ed: Editor | null): void { + if (!ed) return; + // tiptap v3 isActive/getAttributes 在混合选区(heading + 尾随空段落)不可靠 → + // 从选区起点所在块节点直接读级别(toggleHeading 会把末块转标题并追加尾随空段落) + const { from } = ed.state.selection; + const parent = ed.state.doc.resolve(from).parent; + const level = parent.type.name === "heading" ? (parent.attrs.level as number) : 0; + const next = level >= 3 ? 0 : level + 1; // p → h1 → h2 → h3 → p + if (next === 0) ed.chain().focus().setParagraph().run(); + else ed.chain().focus().toggleHeading({ level: next as 1 | 2 | 3 }).run(); +} + +function toggleLink(ed: Editor | null, t: TranslateFn): void { + if (!ed) return; + if (ed.isActive("link")) { + ed.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + const href = window.prompt(t("composer.linkPrompt"), "https://"); + if (!href) return; + ed.chain().focus().extendMarkRange("link").setLink({ href }).run(); +} + +/** 格式栏按钮定义(id → 命令 + 标签类 + i18n key) */ +const FMT_BUTTONS = [ + { id: "bold", label: "B", labelClass: "fmt-label-bold" }, + { id: "italic", label: "I", labelClass: "fmt-label-italic" }, + { id: "strike", label: "S", labelClass: "fmt-label-strike" }, + { id: "code", label: "", labelClass: "fmt-label-code" }, + { id: "link", label: "🔗", labelClass: "" }, + { id: "bullet", label: "•", labelClass: "" }, + { id: "ordered", label: "1.", labelClass: "" }, + { id: "quote", label: "❝", labelClass: "" }, + { id: "heading", label: "H", labelClass: "fmt-label-heading" }, +] as const; + + export function Composer({ store, sid = null, @@ -83,6 +124,19 @@ export function Composer({ menuRef.current = menu; const tRef = useRef(t); tRef.current = t; + // 格式命令 ref 桥接(editor/菜单闭包只创建一次 → 变化值走 ref) + const fmtRef = useRef void>>({}); + fmtRef.current = { + bold: () => editor?.chain().focus().toggleBold().run(), + italic: () => editor?.chain().focus().toggleItalic().run(), + strike: () => editor?.chain().focus().toggleStrike().run(), + code: () => editor?.chain().focus().toggleCode().run(), + bullet: () => editor?.chain().focus().toggleBulletList().run(), + ordered: () => editor?.chain().focus().toggleOrderedList().run(), + quote: () => editor?.chain().focus().toggleBlockquote().run(), + heading: () => cycleHeading(editor), + link: () => toggleLink(editor, tRef.current), + }; // 发送函数解析(默认走 preload 桥) const sendFn = @@ -138,8 +192,29 @@ export function Composer({ void submitRef.current(); return true; } + // 格式快捷键(Stage 2):K/⇧7/⇧8 无内置绑定 → 显式补挂;其余(B/I/⇧S/E/⇧B/Alt 1-3) + // 由 StarterKit 内置 keymap 处理(这里 return false 放行) + const mod = event.metaKey || event.ctrlKey; + if (mod && event.code === "Digit8" && event.shiftKey) { + event.preventDefault(); + fmtRef.current.bullet(); + return true; + } + if (mod && event.code === "Digit7" && event.shiftKey) { + event.preventDefault(); + fmtRef.current.ordered(); + return true; + } + if (mod && event.key.toLowerCase() === "k") { + event.preventDefault(); + fmtRef.current.link(); + return true; + } return false; }, + // Stage 2 粘贴安全:HTML 粘贴路径经 DOMPurify 净化(script/事件属性剥离; + // tiptap-markdown 的纯文本粘贴分支本身无 HTML 注入面) + transformPastedHTML: (html) => DOMPurify.sanitize(html), }, onUpdate: ({ editor }) => { const text = editor.getText(); @@ -163,6 +238,31 @@ export function Composer({ }; }, [editor, editorRef]); + // 格式栏激活态(Stage 2):订阅 editor 事务,按钮高亮当前格式(无 deps → 每次事务重算) + const active = + useEditorState({ + editor, + selector: ({ editor: e }) => ({ + bold: e?.isActive("bold") ?? false, + italic: e?.isActive("italic") ?? false, + strike: e?.isActive("strike") ?? false, + code: e?.isActive("code") ?? false, + bullet: e?.isActive("bulletList") ?? false, + ordered: e?.isActive("orderedList") ?? false, + quote: e?.isActive("blockquote") ?? false, + heading: e?.isActive("heading") ?? false, + }), + }) ?? { + bold: false, + italic: false, + strike: false, + code: false, + bullet: false, + ordered: false, + quote: false, + heading: false, + }; + async function submit(): Promise { if (!editor) return; // tiptap-markdown 的 storage 类型不并入 Editor.storage(Storage 泛型)——运行时存在,显式取值 @@ -244,6 +344,23 @@ export function Composer({ ))} ) : null} +
+ {FMT_BUTTONS.map((b) => ( + + ))} +