784fd56505
The whole-line editor decorate ran on `displayText` after the editor appended the zero-width CURSOR_MARKER and cursor glyph; both start with ESC, so the magic-keyword regex's right-boundary `(?!\S)` rejected `ultrathink` glued to the marker and dropped the gradient until a trailing character was typed. - `Editor.#decorate` (pi-tui) now splits around CURSOR_MARKER and decorates each user-text segment independently, so word-boundary lookarounds resolve correctly on both sides; the matching comment block is corrected. - `KeywordHighlighter` / `highlightMagicKeywords` gain an optional `phase` in [0, 1) that cyclically rotates the gradient stops. `0` (default) yields the static palette, so sent-bubble rendering is unaffected. - `CustomEditor.decorateText` derives `phase` from `Date.now()` and chains `setTimeout(SHIMMER_FRAME_MS)` ticks while focused, the buffer holds a magic keyword, and `magicKeywords.enabled` is on — the render itself schedules the next frame, so losing focus, deleting the keyword, or flipping the setting stops the animation on its own. `interactive-mode` wires the repaint hook to `requestComponentRender(editor)` on construction and after `setEditorComponent`. - Adds `hasMagicKeyword(text)` (cheap prose-aware probe) and tests for the seam fix, the phase cycle, the gating, and timer cleanup. Fixes #2475
91 lines
4.0 KiB
TypeScript
91 lines
4.0 KiB
TypeScript
import { beforeAll, describe, expect, it } from "bun:test";
|
|
import { hasMagicKeyword, highlightMagicKeywords } from "@oh-my-pi/pi-coding-agent/modes/magic-keywords";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
|
|
beforeAll(async () => {
|
|
// Gradient palettes read the active theme's color mode.
|
|
await initTheme(false);
|
|
});
|
|
|
|
describe("highlightMagicKeywords", () => {
|
|
it("paints every magic keyword in a single prose pass, preserving visible text", () => {
|
|
const input = "first ultrathink then orchestrate the workflowz";
|
|
const decorated = highlightMagicKeywords(input);
|
|
expect(decorated).not.toBe(input);
|
|
expect(decorated).toContain("\x1b[38");
|
|
expect(Bun.stripANSI(decorated)).toBe(input);
|
|
// Each keyword is gradient-painted character-by-character, so none survives as a
|
|
// contiguous run in the decorated output.
|
|
for (const keyword of ["ultrathink", "orchestrate", "workflowz"]) {
|
|
expect(decorated).not.toContain(keyword);
|
|
expect(Bun.stripANSI(decorated)).toContain(keyword);
|
|
}
|
|
});
|
|
|
|
it("never paints keywords inside code spans, fenced blocks, or XML sections", () => {
|
|
const input = "`ultrathink`\n```\norchestrate\n```\n<x>workflowz</x>";
|
|
expect(highlightMagicKeywords(input)).toBe(input);
|
|
});
|
|
|
|
it("paints only the prose occurrence when the keyword also appears in code", () => {
|
|
const decorated = highlightMagicKeywords("`orchestrate` but please orchestrate now");
|
|
// The code-span occurrence stays literal; the prose one is split by gradient escapes.
|
|
expect(decorated).toContain("`orchestrate`");
|
|
expect(Bun.stripANSI(decorated)).toBe("`orchestrate` but please orchestrate now");
|
|
// Exactly one prose occurrence painted ⇒ one contiguous "orchestrate" remains (the code one).
|
|
expect(decorated.split("orchestrate").length - 1).toBe(1);
|
|
});
|
|
|
|
it("restores the supplied foreground after each painted keyword", () => {
|
|
const reset = "\x1b[38;2;1;2;3m";
|
|
const decorated = highlightMagicKeywords("go orchestrate go", reset);
|
|
expect(decorated).toContain(reset);
|
|
// The reset must land before the trailing prose so it keeps the bubble color.
|
|
expect(decorated.endsWith(`${reset} go`)).toBe(true);
|
|
});
|
|
|
|
it("shifts the gradient when phase advances — same visible text, different SGR bytes", () => {
|
|
const text = "go ultrathink now";
|
|
const frame0 = highlightMagicKeywords(text, undefined, 0);
|
|
const frame1 = highlightMagicKeywords(text, undefined, 0.5);
|
|
expect(Bun.stripANSI(frame0)).toBe(text);
|
|
expect(Bun.stripANSI(frame1)).toBe(text);
|
|
// The visible output is unchanged width-wise but the painted bytes differ
|
|
// because the per-stop palette has cycled.
|
|
expect(frame0).not.toBe(frame1);
|
|
});
|
|
|
|
it("treats out-of-range phase values as wrapping into [0, 1)", () => {
|
|
const text = "do ultrathink please";
|
|
// 1.0 wraps back to 0, so the painted output must match.
|
|
expect(highlightMagicKeywords(text, undefined, 1)).toBe(highlightMagicKeywords(text, undefined, 0));
|
|
// Negative phase wraps too — -0.25 ≡ 0.75.
|
|
expect(highlightMagicKeywords(text, undefined, -0.25)).toBe(highlightMagicKeywords(text, undefined, 0.75));
|
|
});
|
|
});
|
|
|
|
describe("hasMagicKeyword", () => {
|
|
it("detects every standalone keyword in prose", () => {
|
|
expect(hasMagicKeyword("please ultrathink this")).toBe(true);
|
|
expect(hasMagicKeyword("now orchestrate everything")).toBe(true);
|
|
expect(hasMagicKeyword("just workflowz the steps")).toBe(true);
|
|
});
|
|
|
|
it("rejects keywords embedded in longer words or paths", () => {
|
|
expect(hasMagicKeyword("ultrathinking is fun")).toBe(false);
|
|
expect(hasMagicKeyword("orchestrate.ts is a file")).toBe(false);
|
|
expect(hasMagicKeyword("workflowzed already")).toBe(false);
|
|
});
|
|
|
|
it("rejects keywords inside code spans, fences, and xml sections", () => {
|
|
expect(hasMagicKeyword("`ultrathink`")).toBe(false);
|
|
expect(hasMagicKeyword("```\norchestrate\n```")).toBe(false);
|
|
expect(hasMagicKeyword("<x>workflowz</x>")).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty / keyword-free text", () => {
|
|
expect(hasMagicKeyword("")).toBe(false);
|
|
expect(hasMagicKeyword("plain message with no keywords")).toBe(false);
|
|
});
|
|
});
|