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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vmark",
"private": true,
"version": "0.9.72",
"version": "0.9.73",
"license": "ISC",
"description": "The plain-text workspace where humans and AI collaborate",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion server/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vmark/mcp-server",
"version": "0.9.72",
"version": "0.9.73",
"description": "MCP server for VMark — the plain-text workspace where humans and AI collaborate",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion server/mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* lockstep with the app is the five-file `sed` in the bump procedure
* (`.claude/rules/40-version-bump.md`). Edit it only through that procedure.
*/
const VERSION = '0.9.72';
const VERSION = '0.9.73';

/**
* WHY `process.exitCode` AND NOT `process.exit()` ON THESE PATHS.
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "vmark"
version = "0.9.72"
version = "0.9.73"
description = "The plain-text workspace where humans and AI collaborate"
authors = ["Xiaolai"]
license = "ISC"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VMark",
"version": "0.9.72",
"version": "0.9.73",
"identifier": "app.vmark",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
24 changes: 17 additions & 7 deletions src/components/Terminal/imeGateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
* State:
* - composing/started/startLen — the composition cycle and its textarea
* snapshot. `started` guards an ORPHAN compositionend (fcitx5/rime
* #659/#948) from trusting a stale snapshot.
* #659/#948) from trusting a stale snapshot; the orphan's own e.data is
* still committed, ASCII included (#1375), unless xterm wrote it.
* - echoText — the text just committed; an insert restating it within the
* same macrotask is the IME echoing the commit, not a fresh keystroke.
* The HOST clears it on the next macrotask (`clearEcho`) — timers are an
Expand Down Expand Up @@ -123,12 +124,21 @@ export function createImeGateMachine(): ImeGateMachine {
const textareaDiff = wasStarted ? textareaValue.slice(startLen) : "";
started = false;
let text = resolveCommit({ eventData, textareaDiff });
// A REAL composition result must be committed even when ASCII — T2
// blocked xterm's keydown path, so nothing else delivers it (D3.1).
// Gated on e.data being NON-EMPTY: empty data is a CANCELLED composition
// (Escape), and the textarea still holds the preedit at that instant —
// falling back to the diff typed the raw pinyin into the shell.
if (!text && wasStarted && eventData) text = eventData;
// A composition result must be committed even when ASCII — T2 blocked
// xterm's keydown path, so nothing else delivers it (D3.1). Gated on
// e.data being NON-EMPTY: empty data is a CANCELLED composition (Escape),
// and the textarea still holds the preedit at that instant — falling back
// to the diff typed the raw pinyin into the shell.
//
// An ORPHAN end qualifies too (#1375). WebKitGTK + fcitx5 never fires
// compositionstart, so every commit arrives as an orphan end — including
// the raw Latin text Rime's Enter confirms — and the insert before it is
// `insertFromComposition`, which neither input() nor xterm takes. With no
// composition behind it, an orphan end answers input()'s ownership
// question: skip it only when xterm's keydown path already wrote this
// keystroke. The claim is read, not spent, so a paired insert that is
// still coming stays dropped too.
if (!text && eventData && (wasStarted || !externalWrote)) text = eventData;
// T3: always clear so xterm's setTimeout(0) finalizer reads "".
if (text && !isEcho(text)) {
return committed(text, { clearTextarea: true, stopEvent: false });
Expand Down
109 changes: 102 additions & 7 deletions src/components/Terminal/setupImeCompositionGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ describe("setupImeCompositionGate — commit decisions", () => {
expect(commits).toEqual(["你好"]);
});

it("orphan compositionend (no start) ignores a stale textarea, commits only non-ASCII e.data (F2)", () => {
it("orphan compositionend (no start) ignores a stale textarea, commits only e.data (F2)", () => {
const { textarea, commits } = makeHarness();
textarea.value = "stale pasted text"; // never part of a composition
fireComposition(textarea, "compositionend", "?"); // ASCII e.data, no start
expect(commits).toEqual([]); // must NOT commit the stale textarea
fireComposition(textarea, "compositionend", "?"); // e.data, no start
expect(commits).toEqual(["?"]); // the event's own text — never the stale textarea
});

it("orphan compositionend commits fresh non-ASCII e.data (fcitx5/rime fresh commit)", () => {
Expand All @@ -96,11 +96,10 @@ describe("setupImeCompositionGate — commit decisions", () => {
expect(commits).toEqual(["abc"]);
});

it("still ignores ASCII from an ORPHAN compositionend (no real composition)", () => {
// The D3.1 fallback is gated on a real compositionstart; an orphan ASCII end
// must NOT commit (would inject stale/garbage — F2).
it("commits nothing for an orphan compositionend with empty data", () => {
const { textarea, commits } = makeHarness();
fireComposition(textarea, "compositionend", "abc"); // no start
textarea.value = "stale";
fireComposition(textarea, "compositionend", ""); // no start, no data
expect(commits).toEqual([]);
});

Expand Down Expand Up @@ -144,6 +143,102 @@ describe("setupImeCompositionGate — commit decisions", () => {
});
});

// #1375. WebKitGTK + fcitx5 never fires compositionstart (#948's instrumented
// trace), so EVERY commit is an orphan end. WebKit's Editor::setComposition
// inserts the confirmed text as `insertFromComposition` with isComposing=true,
// THEN dispatches compositionend — no start guard. Rime's Enter commits the raw
// Latin preedit through exactly that path; dropping ASCII orphans lost it while
// CJK commits got through, and nothing else carries it: the insert is not
// `insertText`, so neither the gate's input path nor xterm's _inputEvent takes it.
describe("setupImeCompositionGate — WebKitGTK orphan commits (#1375)", () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = "";
});
afterEach(() => vi.useRealTimers());

/** The event sequence WebKitGTK produces for one fcitx5 commit. */
function fireWebKitGtkCommit(ta: HTMLTextAreaElement, text: string) {
ta.value += text;
ta.dispatchEvent(
new InputEvent("input", {
data: text,
inputType: "insertFromComposition",
isComposing: true,
bubbles: true,
}),
);
fireComposition(ta, "compositionend", text);
}

it("commits raw Latin text that Rime's Enter confirms", () => {
const { textarea, commits } = makeHarness();
fireImeKeydown(textarea); // the Enter the IME consumed
fireWebKitGtkCommit(textarea, "claude");
expect(commits).toEqual(["claude"]);
});

it("commits raw pinyin confirmed with Enter", () => {
const { textarea, commits } = makeHarness();
fireWebKitGtkCommit(textarea, "nihao");
expect(commits).toEqual(["nihao"]);
});

it("commits a CJK commit exactly once through the same sequence (#948)", () => {
const { textarea, commits } = makeHarness();
fireWebKitGtkCommit(textarea, "你好");
expect(commits).toEqual(["你好"]);
});

it("clears the textarea so xterm's composition finalizer reads nothing", () => {
const { textarea } = makeHarness();
fireWebKitGtkCommit(textarea, "claude");
expect(textarea.value).toBe("");
});

it("commits the same word again when it is confirmed in a later task", () => {
const { textarea, commits } = makeHarness();
fireWebKitGtkCommit(textarea, "ls");
vi.advanceTimersByTime(1);
fireWebKitGtkCommit(textarea, "ls");
expect(commits).toEqual(["ls", "ls"]);
});

it("does not re-commit an orphan end re-fired in the same task", () => {
const { textarea, commits } = makeHarness();
fireWebKitGtkCommit(textarea, "claude");
fireComposition(textarea, "compositionend", "claude");
expect(commits).toEqual(["claude"]);
});

it("does not double a character xterm's keydown path already wrote", () => {
const { textarea, handle, commits } = makeHarness();
firePlainKeydown(textarea, "a", 65);
handle.noteExternalWrite("a"); // xterm forwarded this keystroke
fireComposition(textarea, "compositionend", "a");
expect(commits).toEqual([]);
});

it("commits once the previous keystroke's write claim has expired", () => {
const { textarea, handle, commits } = makeHarness();
handle.noteExternalWrite("a");
vi.advanceTimersByTime(1); // a different task: that write cannot own this commit
fireWebKitGtkCommit(textarea, "claude");
expect(commits).toEqual(["claude"]);
});

it("still lets a real composition commit ASCII while xterm holds a claim", () => {
// A STARTED composition's result is the IME's by construction (D3.1); the
// write claim only arbitrates inserts with no composition behind them.
const { textarea, handle, commits } = makeHarness();
fireComposition(textarea, "compositionstart");
handle.noteExternalWrite("a");
textarea.value = "abc";
fireComposition(textarea, "compositionend", "abc");
expect(commits).toEqual(["abc"]);
});
});

describe("setupImeCompositionGate — ASCII claimed by an IME keydown (#1176)", () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
97 changes: 97 additions & 0 deletions src/plugins/codePreview/displayMathTag.webkit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Real-WebKit tier — a display equation's `\tag` sits at the right edge of
* the BLOCK, not on the last term (#1376, #1402).
*
* jsdom computes no layout, so the node-tier
* `plugins/latex/displayMathTag.test.ts` can only read stylesheets. That is
* how #1376 shipped as fixed while users still saw the overlap: its rule and
* its test both named a class nothing renders. This file measures the real
* thing — the stylesheets the app loads, the renderer and sanitizer the
* preview uses, inside the containers codePreview creates.
*
* It lives in codePreview, not latex: the containers it measures are
* codePreview's, and codePreview is the fence-preview hub that the
* `plugin-isolation` dependency rule licenses to import the latex plugin. From
* `plugins/latex/` the same imports are a cross-plugin violation.
*
* **The failure is constructed, not assumed.** The first case forces
* `.katex-display` back to `width: auto` and asserts the probe SEES the
* overlap. Without it, a measurement that always reported "at the edge" would
* make every other assertion vacuously true.
*/
import "katex/dist/katex.min.css";
import "@/styles/katexFixes.css";
import "./code-preview.css";
import { describe, it, expect, afterEach } from "vitest";
import { renderLatex } from "@/plugins/latex";
import { sanitizeKatex } from "@/utils/sanitize";

/** Short enough that a shrink-wrapped block leaves the tag on the equation. */
const EQUATION = "\\sigma_{\\mathrm{c}}=f_{\\mathrm{c}}\\tag{4}";
const CONTAINER_WIDTH = 600;
/** Sub-pixel rounding allowance for edge comparisons. */
const EDGE_TOLERANCE = 2;

interface Geometry {
/** Right edge of the container's content box. */
contentRight: number;
/** Right edge of the tag. */
tagRight: number;
/** Left edge of the tag. */
tagLeft: number;
/** Right edge of the equation body (the last `.katex-base`). */
equationRight: number;
}

async function renderInto(className: string, narrowBlock = false): Promise<Geometry> {
const container = document.createElement("div");
container.className = className;
container.style.width = `${CONTAINER_WIDTH}px`;
container.innerHTML = sanitizeKatex(await renderLatex(EQUATION));
document.body.appendChild(container);

const display = container.querySelector<HTMLElement>(".katex-display");
expect(display, "renderLatex must produce display-mode output").not.toBeNull();
if (narrowBlock) display!.style.width = "auto";

// KaTeX 0.18 class names: the tag is `.katex-tag`, each equation run a
// `.katex-base` (older releases used bare `.tag` / `.base`).
const tag = container.querySelector<HTMLElement>(".katex-html > .katex-tag");
expect(tag, "KaTeX must render the \\tag element").not.toBeNull();
const bases = container.querySelectorAll<HTMLElement>(".katex-html > .katex-base");
expect(bases.length).toBeGreaterThan(0);

const box = container.getBoundingClientRect();
const style = getComputedStyle(container);
const tagBox = tag!.getBoundingClientRect();
return {
contentRight: box.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth),
tagRight: tagBox.right,
tagLeft: tagBox.left,
equationRight: bases[bases.length - 1].getBoundingClientRect().right,
};
}

describe("display-math \\tag placement in real WebKit", () => {
afterEach(() => {
document.body.replaceChildren();
});

it("the probe sees the overlap when the block shrink-wraps (constructed failure)", async () => {
const g = await renderInto("code-block-preview latex-preview", true);
// The tag starts before the equation ends — it is drawn on top of it.
expect(g.tagLeft).toBeLessThan(g.equationRight);
expect(g.contentRight - g.tagRight).toBeGreaterThan(EDGE_TOLERANCE);
});

for (const [label, className] of [
["the rendered preview", "code-block-preview latex-preview"],
["the editing preview", "code-block-live-preview latex-live-preview"],
] as const) {
it(`${label}: the tag reaches the block edge and clears the equation`, async () => {
const g = await renderInto(className);
expect(Math.abs(g.contentRight - g.tagRight)).toBeLessThanOrEqual(EDGE_TOLERANCE);
expect(g.tagLeft).toBeGreaterThanOrEqual(g.equationRight);
});
}
});
Loading