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 src/config/types.mayros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export type MayrosConfig = {
ui?: {
/** Accent color for Mayros UI chrome (hex). */
seamColor?: string;
/** TUI color theme preset. */
theme?: "dark" | "light" | "high-contrast";
/** TUI color theme preset, or "auto" to detect from the terminal. */
theme?: "auto" | "dark" | "light" | "high-contrast";
/** Enable vim editing mode in the TUI. */
vim?: boolean;
/** Custom keybindings for TUI actions. */
Expand Down
39 changes: 34 additions & 5 deletions src/tui/components/assistant-message.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
import { Container, Markdown, Spacer } from "@earendil-works/pi-tui";
import { findStableBoundary } from "../stream-boundary.js";
import { markdownTheme, theme } from "../theme/theme.js";

/**
* Renders an assistant message with incremental markdown parsing.
*
* The text is split at the last stable block boundary (see findStableBoundary)
* into a stable prefix and an active suffix. Only the suffix is re-parsed on
* each streaming delta; the prefix reuses the Markdown component's internal
* render cache, turning the per-delta cost from O(n²) into O(n).
*/
export class AssistantMessageComponent extends Container {
private body: Markdown;
private stablePrefix: Markdown;
private activeSuffix: Markdown;
private committedPrefixText = "";

constructor(text: string) {
super();
this.body = new Markdown(text, 1, 0, markdownTheme, {
this.addChild(new Spacer(1));
this.stablePrefix = this.createMarkdown("");
this.activeSuffix = this.createMarkdown("");
this.addChild(this.stablePrefix);
this.addChild(this.activeSuffix);
if (text) this.setText(text);
}

private createMarkdown(text: string): Markdown {
return new Markdown(text, 1, 0, markdownTheme, {
// Keep assistant body text in terminal default foreground so contrast
// follows the user's terminal theme (dark or light).
color: (line) => theme.assistantText(line),
});
this.addChild(new Spacer(1));
this.addChild(this.body);
}

setText(text: string) {
this.body.setText(text);
const boundary = findStableBoundary(text);
const prefixText = boundary > 0 ? text.slice(0, boundary) : "";
const suffixText = boundary > 0 ? text.slice(boundary) : text;

// Only update the prefix when it actually grew — this is what lets the
// Markdown cache hit on every delta once a block is committed.
if (prefixText !== this.committedPrefixText) {
this.committedPrefixText = prefixText;
this.stablePrefix.setText(prefixText);
}
// The suffix always changes on each delta.
this.activeSuffix.setText(suffixText);
}
}
8 changes: 8 additions & 0 deletions src/tui/components/chat-log.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Component } from "@earendil-works/pi-tui";
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
import type { SectionState } from "../tui-types.js";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { ToolExecutionComponent } from "./tool-execution.js";
Expand Down Expand Up @@ -207,4 +208,11 @@ export class ChatLog extends Container {
tool.setExpanded(expanded);
}
}

setToolSectionState(state: SectionState) {
this.toolsExpanded = state === "expanded";
for (const tool of this.toolById.values()) {
tool.setSectionState(state);
}
}
}
26 changes: 22 additions & 4 deletions src/tui/components/tool-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class ToolExecutionComponent extends Container {
private toolName: string;
private args: unknown;
private result?: ToolResult;
private expanded = false;
private sectionState: import("../tui-types.js").SectionState = "collapsed";
private isError = false;
private isPartial = true;

Expand Down Expand Up @@ -92,10 +92,20 @@ export class ToolExecutionComponent extends Container {
}

setExpanded(expanded: boolean) {
this.expanded = expanded;
this.sectionState = expanded ? "expanded" : "collapsed";
this.refresh();
}

setSectionState(state: import("../tui-types.js").SectionState) {
this.sectionState = state;
this.refresh();
}

/** When hidden, the component renders only the header line. */
get isHidden(): boolean {
return this.sectionState === "hidden";
}

setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) {
this.result = result;
this.isPartial = false;
Expand Down Expand Up @@ -132,6 +142,14 @@ export class ToolExecutionComponent extends Container {
const raw = extractText(this.result);
const isDiff = DIFF_TOOLS.has(this.toolName) && raw && !this.isPartial;

// Hidden: only the header line, no output body.
if (this.sectionState === "hidden") {
this.output.setText("");
return;
}

const isExpanded = this.sectionState === "expanded";

if (isDiff) {
const colored = renderDiff(raw, {
add: theme.success,
Expand All @@ -145,7 +163,7 @@ export class ToolExecutionComponent extends Container {
add: theme.success,
del: theme.error,
});
const maxLines = this.expanded ? Infinity : DIFF_PREVIEW_LINES;
const maxLines = isExpanded ? Infinity : DIFF_PREVIEW_LINES;
const display =
colored.length > maxLines
? [...colored.slice(0, maxLines), "…", statsLine]
Expand All @@ -157,7 +175,7 @@ export class ToolExecutionComponent extends Container {
: this.isPartial
? "…"
: "";
if (!this.expanded && text) {
if (!isExpanded && text) {
const lines = text.split("\n");
const preview =
lines.length > PREVIEW_LINES ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…` : text;
Expand Down
2 changes: 1 addition & 1 deletion src/tui/components/welcome-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export class WelcomeScreen implements Component {
["/help", "all commands"],
["/agents", "switch agents"],
["Ctrl+V", "paste images"],
["Esc", "abort run"],
["Ctrl+C", "abort run / exit"],
];

const sessionHeader = bold(accent("Session"));
Expand Down
82 changes: 82 additions & 0 deletions src/tui/paste-grapheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

/**
* No-regression coverage for the paste-collapse and grapheme-aware caret
* behavior inherited from the terminal UI dependency.
*
* Both features are implemented inside the Editor class the TUI extends, so
* these tests pin the environment contract (Intl.Segmenter availability and
* the paste-marker shape) rather than the Editor internals. If either
* contract breaks, the TUI's caret or paste handling degrades silently.
*/

describe("grapheme-aware caret contract", () => {
it("Intl.Segmenter is available with grapheme granularity", () => {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
expect(segmenter).toBeDefined();
const segments = [...segmenter.segment("ab")];
expect(segments.length).toBe(2);
});

it("treats emoji with skin-tone modifiers as a single grapheme", () => {
// 👩🏻 is woman (U+1F469) + light skin tone (U+1F3FB) — two code points,
// one user-perceived character. The caret must jump over it atomically.
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = [...segmenter.segment("👩🏻")];
expect(segments.length).toBe(1);
});

it("treats a flag emoji (regional indicator pair) as a single grapheme", () => {
// 🇲🇽 is U+1F1F2 + U+1F1FD — two regional indicator symbols, one flag.
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = [...segmenter.segment("🇲🇽")];
expect(segments.length).toBe(1);
});

it("treats CJK characters as one grapheme each", () => {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = [...segmenter.segment("你好")];
expect(segments.length).toBe(2);
});
});

/**
* The paste-marker shape used by the Editor to collapse large pastes into a
* single atomic token. The regex must match the formats the Editor emits so
* that cursor movement and submission expansion keep working. If the format
* ever changes upstream, this test surfaces the break.
*/
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/;

describe("paste-collapse marker contract", () => {
it("matches a multi-line paste marker", () => {
expect(PASTE_MARKER_REGEX.test("[paste #1 +42 lines]")).toBe(true);
});

it("matches a character-count paste marker", () => {
expect(PASTE_MARKER_REGEX.test("[paste #2 1500 chars]")).toBe(true);
});

it("matches a bare paste marker (no count)", () => {
expect(PASTE_MARKER_REGEX.test("[paste #3]")).toBe(true);
});

it("does not match arbitrary bracketed text", () => {
expect(PASTE_MARKER_REGEX.test("[not a paste]")).toBe(false);
expect(PASTE_MARKER_REGEX.test("[paste foo]")).toBe(false);
});

it("a paste marker is a single atomic grapheme for cursor movement", () => {
// When the Editor segments text containing a paste marker, the marker
// must be treated as one unit so arrow keys jump over it in one press.
// We approximate this by checking the marker has no internal whitespace
// graphemes that a naive split would create.
const marker = "[paste #1 +42 lines]";
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = [...segmenter.segment(marker)];
// The marker contains spaces, so it has multiple graphemes — the Editor
// applies its own marker-aware segmentation on top. This test documents
// that the marker is a compact, single-line token (no embedded newlines).
expect(segments.some((s) => s.segment.includes("\n"))).toBe(false);
});
});
28 changes: 28 additions & 0 deletions src/tui/section-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { SECTION_CYCLE, nextSectionState } from "./tui-types.js";

describe("nextSectionState", () => {
it("cycles hidden → collapsed", () => {
expect(nextSectionState("hidden")).toBe("collapsed");
});

it("cycles collapsed → expanded", () => {
expect(nextSectionState("collapsed")).toBe("expanded");
});

it("cycles expanded → hidden (wraps around)", () => {
expect(nextSectionState("expanded")).toBe("hidden");
});

it("the cycle covers all three states in order", () => {
expect(SECTION_CYCLE).toEqual(["hidden", "collapsed", "expanded"]);
});

it("produces a full cycle back to the start after 3 steps", () => {
let state: "hidden" | "collapsed" | "expanded" = "hidden";
state = nextSectionState(state);
state = nextSectionState(state);
state = nextSectionState(state);
expect(state).toBe("hidden");
});
});
69 changes: 69 additions & 0 deletions src/tui/stream-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { findStableBoundary } from "./stream-boundary.js";

describe("findStableBoundary", () => {
it("returns 0 for empty text", () => {
expect(findStableBoundary("")).toBe(0);
});

it("returns 0 for a single paragraph with no blank line", () => {
expect(findStableBoundary("hello world")).toBe(0);
});

it("splits at the last blank line between paragraphs", () => {
const text = "first paragraph\n\nsecond paragraph";
const boundary = findStableBoundary(text);
expect(text.slice(0, boundary)).toBe("first paragraph\n\n");
expect(text.slice(boundary)).toBe("second paragraph");
});

it("splits at the last of multiple blank lines", () => {
const text = "para one\n\npara two\n\npara three";
const boundary = findStableBoundary(text);
expect(text.slice(boundary)).toBe("para three");
});

it("keeps an open code fence in the suffix (does not split mid-fence)", () => {
const text = "intro\n\n```ts\nconst x = 1;\n// still in fence";
const boundary = findStableBoundary(text);
// The blank line before the fence opens is a stable boundary (fence count
// is 0 there), so "intro" becomes the stable prefix and the open fence
// stays in the suffix — which is correct, the prefix is a complete block.
expect(text.slice(0, boundary)).toBe("intro\n\n");
expect(text.slice(boundary)).toBe("```ts\nconst x = 1;\n// still in fence");
});

it("splits after a closed code fence", () => {
const text = "intro\n\n```ts\nconst x = 1;\n```\n\nnext paragraph";
const boundary = findStableBoundary(text);
expect(text.slice(boundary)).toBe("next paragraph");
expect(text.slice(0, boundary)).toContain("```");
expect(text.slice(0, boundary)).toContain("```ts");
});

it("handles multiple closed fences", () => {
const text = "a\n\n```ts\ncode1\n```\n\nb\n\n```py\ncode2\n```\n\nc";
const boundary = findStableBoundary(text);
expect(text.slice(boundary)).toBe("c");
});

it("treats tilde fences the same as backtick fences", () => {
const text = "intro\n\n~~~ts\nconst x = 1;\n~~~\n\nafter";
const boundary = findStableBoundary(text);
expect(text.slice(boundary)).toBe("after");
});

it("returns 0 when the only blank line is inside an open fence", () => {
const text = "```ts\n\nconst x = 1;\n// open";
const boundary = findStableBoundary(text);
expect(boundary).toBe(0);
});

it("handles trailing newlines", () => {
const text = "paragraph\n\n";
const boundary = findStableBoundary(text);
// The boundary is after the blank line; suffix is empty.
expect(text.slice(0, boundary)).toBe("paragraph\n\n");
expect(text.slice(boundary)).toBe("");
});
});
Loading
Loading