From 0eb1497a611c3f5758199fc2d0eec9046379c6f9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 10 Aug 2026 15:18:52 +0200 Subject: [PATCH 01/11] feat(coding-agent): highlight @path references and --flags in editor, queue previews, and user messages --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/custom-editor.ts | 20 ++ .../components/prompt-highlight.ts | 188 ++++++++++++++++++ .../components/slash-command-message.ts | 6 +- .../interactive/components/user-message.ts | 53 ++--- .../src/modes/interactive/interactive-mode.ts | 6 +- .../coding-agent/test/custom-editor.test.ts | 61 ++++++ .../coding-agent/test/user-message.test.ts | 60 ++++++ packages/tui/src/components/editor.ts | 20 ++ 9 files changed, 371 insertions(+), 44 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579a..abb9e1cf20 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Highlighted `@path` file references and `--flags` in the editor, queued message previews, and sent user messages. - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index ddcc9d5dcb..f83109a0d6 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -8,6 +8,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js"; +import { ArgTokenHighlighter } from "./prompt-highlight.js"; export interface CustomEditorOptions extends EditorOptions { placeholder?: string; @@ -25,6 +26,7 @@ export class CustomEditor extends Editor { private placeholder: string | undefined; private readonly placeholderColor: (text: string) => string; private readonly isArgumentCommand: (name: string) => boolean; + private readonly argTokenHighlighter = new ArgTokenHighlighter(); public actionHandlers: Map void> = new Map(); // Special handlers that can be dynamically replaced @@ -69,6 +71,23 @@ export class CustomEditor extends Editor { layoutLineIndex: number, lineText: string, cursorCol: number | undefined, + sourceLine?: number, + sourceStart?: number, + ): string { + if (sourceLine === undefined || sourceStart === undefined || this.getBashPromptInfo(this.getLines()[0] ?? "")) { + return this.styleCommandToken(displayText, layoutLineIndex, lineText, cursorCol); + } + // Arg tokens are styled first: their spans start after the command + // token, so the command offsets below stay valid. + const highlighted = this.argTokenHighlighter.highlightLine(displayText, lineText, sourceLine, sourceStart); + return this.styleCommandToken(highlighted, layoutLineIndex, lineText, cursorCol); + } + + private styleCommandToken( + displayText: string, + layoutLineIndex: number, + lineText: string, + cursorCol: number | undefined, ): string { const commandColor = this.commandColor; if (!commandColor || layoutLineIndex !== 0) { @@ -123,6 +142,7 @@ export class CustomEditor extends Editor { } override render(width: number): string[] { + this.argTokenHighlighter.reset(this.getLines()); let lines = super.render(width); if (this.placeholder && this.getText().length === 0 && lines.length >= 2) { lines = [lines[0]!, this.renderPlaceholderLine(width), ...lines.slice(2)]; diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts new file mode 100644 index 0000000000..759b5c11dc --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -0,0 +1,188 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; +import { type ThemeColor, theme } from "../theme/theme.js"; + +const ARG_TOKEN_PATTERN = /@"[^"]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*/g; +const FG_SGR_PATTERN = /\x1b\[(?:0|39|3[0-7]|9[0-7]|38;[0-9;]+)m/g; +/** Escape sequences the editor splices into displayed text (cursor highlight, IME marker). */ +const CURSOR_ESCAPE_PATTERN = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g; + +const MASK_BASE = "\uE000"; +const MASK_EXTRA_WIDTH = "\uFF9E"; +const MASK_ZERO_WIDTH = "\u2060"; +const MASK_PATTERN = /\u2060|\uE000\uFF9E*/gu; + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +interface ArgTokenSpan { + start: number; + end: number; + color: ThemeColor; +} + +function tokenColor(token: string): ThemeColor { + return token.startsWith("@") ? "success" : "mdLink"; +} + +function hasTokenBoundary(text: string, index: number): boolean { + return index === 0 || /\s/.test(text.charAt(index - 1)); +} + +/** Finds @path references and --flags in plain (unrendered) text. */ +function findArgTokens(text: string, fromIndex = 0): ArgTokenSpan[] { + const spans: ArgTokenSpan[] = []; + for (const match of text.matchAll(ARG_TOKEN_PATTERN)) { + if (match.index < fromIndex || !hasTokenBoundary(text, match.index)) continue; + spans.push({ start: match.index, end: match.index + match[0].length, color: tokenColor(match[0]) }); + } + return spans; +} + +/** Foreground SGR active at index; theme.fg() closes with \x1b[39m, so it must be re-emitted. */ +function activeFgBefore(line: string, index: number): string { + let active = ""; + for (const sgr of line.slice(0, index).matchAll(FG_SGR_PATTERN)) { + active = sgr[0] === "\x1b[0m" ? "" : sgr[0]; + } + return active; +} + +function maskGrapheme(grapheme: string): string { + const width = visibleWidth(grapheme); + return width === 0 ? MASK_ZERO_WIDTH : MASK_BASE + MASK_EXTRA_WIDTH.repeat(width - 1); +} + +/** Styles @path references and --flags in a plain (unrendered) string. */ +export function styleArgumentTokens( + text: string, + styleOther: (segment: string) => string = (segment) => segment, +): string { + let result = ""; + let offset = 0; + for (const token of findArgTokens(text)) { + result += styleOther(text.slice(offset, token.start)) + theme.fg(token.color, text.slice(token.start, token.end)); + offset = token.end; + } + return result + styleOther(text.slice(offset)); +} + +/** + * Replaces the leading slash command (optional) and every @path/--flag token + * in a prompt with same-width placeholders before markdown layout, so the + * themed text can be spliced back into rendered lines after wrapping. + */ +export class PromptTokenMask { + readonly text: string; + private readonly graphemes: { segment: string; color: ThemeColor }[] = []; + private offset = 0; + + constructor(source: string, commandEnd = 0) { + const tokens: ArgTokenSpan[] = []; + if (commandEnd > 0) { + tokens.push({ start: 0, end: commandEnd, color: "accent" }); + } + tokens.push(...findArgTokens(source, commandEnd)); + + let text = ""; + let cursor = 0; + for (const token of tokens) { + text += source.slice(cursor, token.start); + for (const { segment } of graphemeSegmenter.segment(source.slice(token.start, token.end))) { + this.graphemes.push({ segment, color: token.color }); + text += maskGrapheme(segment); + } + cursor = token.end; + } + this.text = text + source.slice(cursor); + } + + /** Placeholders are consumed in order across lines; rewind before each render pass. */ + reset(): void { + this.offset = 0; + } + + restoreLine(line: string): string { + let result = ""; + let copied = 0; + let run: { start: number; end: number; color: ThemeColor; text: string } | undefined; + const flush = () => { + if (!run) return; + result += line.slice(copied, run.start) + theme.fg(run.color, run.text) + activeFgBefore(line, run.start); + copied = run.end; + run = undefined; + }; + for (const match of line.matchAll(MASK_PATTERN)) { + const grapheme = this.graphemes[this.offset]; + if (!grapheme) break; // literal mask-range character from the source; leave it untouched + this.offset++; + if (run && run.color === grapheme.color && run.end === match.index) { + run.text += grapheme.segment; + run.end += match[0].length; + } else { + flush(); + run = { + start: match.index, + end: match.index + match[0].length, + color: grapheme.color, + text: grapheme.segment, + }; + } + } + flush(); + return result + line.slice(copied); + } +} + +/** + * Styles @path references and --flags in laid-out editor lines. Token spans + * are computed on the logical source lines before wrapping, so wrapped + * fragments, quoted paths with spaces, and line-leading tokens are all + * colored exactly. Call reset() with the current lines before each render + * pass; each chunk carries its exact source coordinates. + */ +export class ArgTokenHighlighter { + private spans: ArgTokenSpan[][] = []; + + reset(lines: readonly string[]): void { + this.spans = lines.map((line) => findArgTokens(line)); + } + + /** + * Styles the tokens covered by one laid-out chunk. displayText is the + * chunk with any cursor escape sequences already spliced in; chunkText is + * the raw chunk text starting at sourceStart within source line sourceLine. + */ + highlightLine(displayText: string, chunkText: string, sourceLine: number, sourceStart: number): string { + const rangeEnd = sourceStart + chunkText.length; + const spans: ArgTokenSpan[] = []; + for (const span of this.spans[sourceLine] ?? []) { + if (span.end <= sourceStart) continue; + if (span.start >= rangeEnd) break; + spans.push({ + start: Math.max(span.start, sourceStart) - sourceStart, + end: Math.min(span.end, rangeEnd) - sourceStart, + color: span.color, + }); + } + if (spans.length === 0) return displayText; + + // Map visible code-unit offsets to displayText offsets, skipping the + // escape sequences the editor spliced in for the cursor. + const visibleStart: number[] = []; + let pos = 0; + for (const seq of displayText.matchAll(CURSOR_ESCAPE_PATTERN)) { + for (; pos < seq.index; pos++) visibleStart.push(pos); + pos = seq.index + seq[0].length; + } + for (; pos < displayText.length; pos++) visibleStart.push(pos); + + let result = ""; + let copied = 0; + for (const span of spans) { + const start = visibleStart[span.start] ?? displayText.length; + const end = (visibleStart[span.end - 1] ?? displayText.length - 1) + 1; + result += displayText.slice(copied, start) + theme.fg(span.color, displayText.slice(start, end)); + copied = end; + } + return result + displayText.slice(copied); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts index d977028edd..a580f75e62 100644 --- a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts @@ -1,6 +1,7 @@ import { Box, Container, Text } from "@earendil-works/pi-tui"; import { parseSlashCommand } from "../../../core/slash-commands.js"; import { theme } from "../theme/theme.js"; +import { styleArgumentTokens } from "./prompt-highlight.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; @@ -11,7 +12,10 @@ export function isLeadingSlashCommand(text: string, isRecognized: (name: string) return command !== undefined && isRecognized(command.name); } -export function styleSlashCommandText(text: string, styleRest: (rest: string) => string = (rest) => rest): string { +export function styleSlashCommandText( + text: string, + styleRest: (rest: string) => string = (rest) => styleArgumentTokens(rest), +): string { const parsed = parseSlashCommand(text); const commandEnd = parsed ? parsed.name.length + 1 : text.length; return `${theme.fg("accent", text.slice(0, commandEnd))}${styleRest(text.slice(commandEnd))}`; diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 2674f52b31..9d4846375f 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -1,51 +1,26 @@ -import { Box, type Component, Container, Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-tui"; +import { Box, type Component, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import { parseSlashCommand } from "../../../core/slash-commands.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { isLeadingSlashCommand } from "./slash-command-message.js"; +import { PromptTokenMask } from "./prompt-highlight.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; const OSC133_ZONE_FINAL = "\x1b]133;C\x07"; -const COMMAND_MASK_BASE = "\uE000"; -const COMMAND_MASK_EXTRA_WIDTH = "\uFF9E"; -const COMMAND_MASK_ZERO_WIDTH = "\u2060"; -const COMMAND_MASK_PATTERN = /\u2060|\uE000\uFF9E*/gu; -const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); -class SlashCommandMarkdown implements Component { +class HighlightedMarkdown implements Component { private readonly markdown: Markdown; - private readonly commandGraphemes: string[]; + private readonly mask: PromptTokenMask; - constructor(text: string, markdownTheme: MarkdownTheme) { - const parsed = parseSlashCommand(text); - const commandEnd = parsed ? parsed.name.length + 1 : text.length; - this.commandGraphemes = [...graphemeSegmenter.segment(text.slice(0, commandEnd))].map(({ segment }) => segment); - const placeholder = this.commandGraphemes - .map((grapheme) => { - const width = visibleWidth(grapheme); - return width === 0 - ? COMMAND_MASK_ZERO_WIDTH - : COMMAND_MASK_BASE + COMMAND_MASK_EXTRA_WIDTH.repeat(width - 1); - }) - .join(""); - this.markdown = new Markdown(`${placeholder}${text.slice(commandEnd)}`, 0, 0, markdownTheme, { + constructor(text: string, markdownTheme: MarkdownTheme, commandEnd = 0) { + this.mask = new PromptTokenMask(text, commandEnd); + this.markdown = new Markdown(this.mask.text, 0, 0, markdownTheme, { color: (content: string) => theme.fg("userMessageText", content), }); } render(width: number): string[] { - let commandOffset = 0; - return this.markdown.render(width).map((line) => { - const chunks: string[] = []; - const replaced = line.replace(COMMAND_MASK_PATTERN, (placeholder) => { - const grapheme = this.commandGraphemes[commandOffset]; - if (grapheme === undefined) return placeholder; - commandOffset++; - chunks.push(grapheme); - return ""; - }); - return chunks.length === 0 ? replaced : `${theme.fg("accent", chunks.join(""))}${replaced}`; - }); + this.mask.reset(); + return this.markdown.render(width).map((line) => this.mask.restoreLine(line)); } invalidate(): void { @@ -65,14 +40,10 @@ export class UserMessageComponent extends Container { isRecognizedSlashCommand: (name: string) => boolean = () => false, ) { super(); + const command = parseSlashCommand(text); + const commandEnd = command && isRecognizedSlashCommand(command.name) ? command.name.length + 1 : 0; this.contentBox = new Box(2, 1, (content: string) => theme.getUserMessageBackgroundColor()(content)); - this.contentBox.addChild( - isLeadingSlashCommand(text, isRecognizedSlashCommand) - ? new SlashCommandMarkdown(text, markdownTheme) - : new Markdown(text, 0, 0, markdownTheme, { - color: (content: string) => theme.fg("userMessageText", content), - }), - ); + this.contentBox.addChild(new HighlightedMarkdown(text, markdownTheme, commandEnd)); this.addChild(this.contentBox); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0ec1d8bcd7..8f0b4de0b5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -203,6 +203,7 @@ import { InjectedPromptMessageComponent, isInjectedPromptMessage } from "./compo import { formatKeyText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; import type { AuthSelectorProvider } from "./components/oauth-selector.js"; import { PrimeOnboardingSplashComponent } from "./components/prime-onboarding-splash.js"; +import { styleArgumentTokens } from "./components/prompt-highlight.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { SideQuestionComponent } from "./components/side-question.js"; @@ -309,9 +310,10 @@ export function styleQueuedMessagePreview( isRecognizedSlashCommand: (name: string) => boolean, ): string { const preview = formatQueuedMessagePreview(message, label); - if (!isLeadingSlashCommand(message, isRecognizedSlashCommand)) return theme.fg("dim", preview); + const styleDim = (segment: string) => theme.fg("dim", segment); + if (!isLeadingSlashCommand(message, isRecognizedSlashCommand)) return styleArgumentTokens(preview, styleDim); const prefix = preview.slice(0, preview.length - message.length); - return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest) => theme.fg("dim", rest))}`; + return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest) => styleArgumentTokens(rest, styleDim))}`; } function isExpandable(obj: unknown): obj is Expandable { diff --git a/packages/coding-agent/test/custom-editor.test.ts b/packages/coding-agent/test/custom-editor.test.ts index bef6fc798a..983b3c487d 100644 --- a/packages/coding-agent/test/custom-editor.test.ts +++ b/packages/coding-agent/test/custom-editor.test.ts @@ -3,6 +3,7 @@ import { CURSOR_MARKER, setKeybindings, visibleWidth } from "@earendil-works/pi- import { beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.js"; import { CustomEditor } from "../src/modes/interactive/components/custom-editor.js"; +import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; const passthrough = (text: string) => text; @@ -255,6 +256,66 @@ describe("CustomEditor", () => { } }); + it("highlights @path references and --flags in the input text", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + editor.setText("/new --name foo @src/foo.ts"); + + const line = editor.render(40)[1]!; + + expect(line).toContain(theme.fg("mdLink", "--name")); + expect(line).toContain(theme.fg("success", "@src/foo.ts")); + }); + + it("highlights wrapped @path fragments across editor lines", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + editor.setText("check @src/very-long-file-name.ts please"); + + const rendered = editor.render(16).join("\n"); + + expect(rendered).toContain(theme.fg("success", "@src/very-lon")); + expect(rendered).toContain(theme.fg("success", "g-file-name.t")); + expect(rendered).toContain(theme.fg("success", "s")); + }); + + it("keeps quoted @paths highlighted across wrapped editor lines", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + editor.setText('open @"docs/some very long name.txt" now'); + + const rendered = editor.render(16).join("\n"); + + expect(rendered).toContain(theme.fg("success", '@"docs/some ')); + expect(rendered).toContain(theme.fg("success", "very long ")); + expect(rendered).toContain(theme.fg("success", 'name.txt"')); + }); + + it("does not bleed @path highlighting onto the next line", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + editor.setText("@abcde\nfoo bar"); + + const rendered = editor.render(11).join("\n"); + + expect(rendered).toContain(theme.fg("success", "@abcde")); + expect(rendered).not.toContain(theme.fg("success", "foo")); + }); + + it("does not mis-color visible text matching a scrolled-away token", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + // 9 logical lines with the cursor at the end scroll the first two + // (including the @foo token) out of view; the visible plain "foo" + // must not inherit the hidden token's color. + editor.setText("@foo\nhidden\nfoo\nl3\nl4\nl5\nl6\nl7\nl8"); + + const rendered = editor.render(20).join("\n"); + + expect(rendered).toContain("↑ 2 more"); + expect(rendered).not.toContain(theme.fg("success", "foo")); + }); + it("renders no header when the callback returns undefined", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const withoutCallback = editor.render(40); diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 602dcaa62e..98b377a3c5 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -1,5 +1,6 @@ import { clearDefaultTerminalColors, setDefaultTerminalColors, visibleWidth } from "@earendil-works/pi-tui"; import { afterEach, describe, expect, test } from "vitest"; +import { styleArgumentTokens } from "../src/modes/interactive/components/prompt-highlight.js"; import { UserMessageComponent } from "../src/modes/interactive/components/user-message.js"; import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; @@ -83,6 +84,65 @@ describe("UserMessageComponent", () => { expect(plainLines).toEqual(expectedLines); }); + test("highlights flags and @path references in slash command arguments", () => { + initTheme("dark"); + const rendered = new UserMessageComponent("/new --name foo @src/foo.ts", undefined, (name) => name === "new") + .render(60) + .join("\n"); + + expect(rendered).toContain(theme.fg("accent", "/new")); + expect(rendered).toContain(theme.fg("mdLink", "--name")); + expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); + }); + + test("highlights @path references in plain user messages", () => { + initTheme("dark"); + const rendered = new UserMessageComponent("check @src/foo.ts please").render(60).join("\n"); + + expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); + }); + + test("highlights leading and newline-leading @path references in plain user messages", () => { + initTheme("dark"); + const leading = new UserMessageComponent("@src/foo.ts please").render(60).join("\n"); + const newline = new UserMessageComponent("hello\n@foo").render(60).join("\n"); + + expect(leading).toContain(theme.fg("success", "@src/foo.ts")); + expect(newline).toContain(theme.fg("success", "@foo")); + }); + + test("highlights every wrapped fragment of a long @path", () => { + initTheme("dark"); + const rendered = new UserMessageComponent("check @src/very-long-file-name.ts please").render(16).join("\n"); + + expect(rendered).toContain(theme.fg("success", "@src/very-lo")); + expect(rendered).toContain(theme.fg("success", "ng-file-name")); + expect(rendered).toContain(theme.fg("success", ".ts")); + }); + + test("keeps quoted @paths highlighted across narrow wraps", () => { + initTheme("dark"); + const rendered = new UserMessageComponent('open @"docs/some very long name.txt" now').render(16).join("\n"); + + expect(rendered).toContain(theme.fg("success", '@"docs/some ')); + expect(rendered).toContain(theme.fg("success", "very long na")); + expect(rendered).toContain(theme.fg("success", 'me.txt"')); + }); + + test("does not highlight @ or -- without a whitespace boundary", () => { + initTheme("dark"); + const email = new UserMessageComponent("email me@example.com").render(60).join("\n"); + const dashes = new UserMessageComponent("a---b").render(60).join("\n"); + + expect(email).not.toContain(theme.fg("success", "@example.com")); + expect(dashes).not.toContain(theme.fg("mdLink", "--b")); + }); + + test("styleArgumentTokens highlights quoted @paths", () => { + initTheme("dark"); + expect(styleArgumentTokens('open @"a b.txt" now')).toBe(`open ${theme.fg("success", '@"a b.txt"')} now`); + }); + test("preserves mask-like argument text across narrow wraps", () => { initTheme("dark"); const command = "/averyveryverylongcommand"; diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index b066f007c8..ec7d5d8607 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -226,6 +226,10 @@ interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number; + /** Logical source line index this layout line renders. */ + sourceLine: number; + /** Start offset of this layout line's text within the source line. */ + sourceStart: number; } export interface EditorTheme { @@ -406,6 +410,8 @@ export class Editor implements Component, Focusable { _layoutLineIndex: number, _lineText: string, _cursorCol: number | undefined, + _sourceLine?: number, + _sourceStart?: number, ): string { return displayText; } @@ -659,6 +665,8 @@ export class Editor implements Component, Focusable { absoluteLineIndex, layoutLine.text, layoutLine.hasCursor ? layoutLine.cursorPos : undefined, + layoutLine.sourceLine, + layoutLine.sourceStart, ); // Calculate padding based on actual visible width @@ -1015,6 +1023,8 @@ export class Editor implements Component, Focusable { text: "", hasCursor: true, cursorPos: 0, + sourceLine: 0, + sourceStart: 0, }); return layoutLines; } @@ -1032,6 +1042,8 @@ export class Editor implements Component, Focusable { text: "", hasCursor: isCurrentLine, cursorPos: isCurrentLine ? 0 : undefined, + sourceLine: i, + sourceStart: hiddenPrefixLength, }); continue; } @@ -1043,11 +1055,15 @@ export class Editor implements Component, Focusable { text: displayLine, hasCursor: true, cursorPos: Math.max(0, this.state.cursorCol - hiddenPrefixLength), + sourceLine: i, + sourceStart: hiddenPrefixLength, }); } else { layoutLines.push({ text: displayLine, hasCursor: false, + sourceLine: i, + sourceStart: hiddenPrefixLength, }); } } else { @@ -1091,11 +1107,15 @@ export class Editor implements Component, Focusable { text: chunk.text, hasCursor: true, cursorPos: adjustedCursorPos, + sourceLine: i, + sourceStart: hiddenPrefixLength + chunk.startIndex, }); } else { layoutLines.push({ text: chunk.text, hasCursor: false, + sourceLine: i, + sourceStart: hiddenPrefixLength + chunk.startIndex, }); } } From 9fa87523a1e3a36c4575c03ebf16f3d5eba3848c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 10 Aug 2026 15:20:35 +0200 Subject: [PATCH 02/11] docs(tui): add changelog entry for editor source-coordinate styling hooks --- packages/tui/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index abf78757af..18710a1893 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added source-line coordinates to editor layout lines and `styleDisplayText()` so subclasses can style wrapped or scrolled text against exact source offsets. + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 From f87b0ff6ae58e49b024c39732177ca869a8076c5 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 10 Aug 2026 17:13:57 +0200 Subject: [PATCH 03/11] fix(coding-agent): keep line structure for multi-line quoted @refs and avoid mask corruption from literal placeholder chars --- .../components/prompt-highlight.ts | 8 ++++++- .../coding-agent/test/user-message.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index 759b5c11dc..d2c713a2a9 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -1,7 +1,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { type ThemeColor, theme } from "../theme/theme.js"; -const ARG_TOKEN_PATTERN = /@"[^"]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*/g; +const ARG_TOKEN_PATTERN = /@"[^"\n]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*/g; const FG_SGR_PATTERN = /\x1b\[(?:0|39|3[0-7]|9[0-7]|38;[0-9;]+)m/g; /** Escape sequences the editor splices into displayed text (cursor highlight, IME marker). */ const CURSOR_ESCAPE_PATTERN = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g; @@ -10,6 +10,8 @@ const MASK_BASE = "\uE000"; const MASK_EXTRA_WIDTH = "\uFF9E"; const MASK_ZERO_WIDTH = "\u2060"; const MASK_PATTERN = /\u2060|\uE000\uFF9E*/gu; +/** Literal mask-range characters would alias generated placeholders; messages containing them skip masking. */ +const MASK_LITERAL_PATTERN = /[\u2060\uE000\uFF9E]/u; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); @@ -76,6 +78,10 @@ export class PromptTokenMask { private offset = 0; constructor(source: string, commandEnd = 0) { + if (MASK_LITERAL_PATTERN.test(source)) { + this.text = source; + return; + } const tokens: ArgTokenSpan[] = []; if (commandEnd > 0) { tokens.push({ start: 0, end: commandEnd, color: "accent" }); diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 98b377a3c5..bf73d9fda3 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -129,6 +129,17 @@ describe("UserMessageComponent", () => { expect(rendered).toContain(theme.fg("success", 'me.txt"')); }); + test("keeps multi-line quoted @paths on separate lines", () => { + initTheme("dark"); + const lines = new UserMessageComponent('@"a\nb"').render(60); + const plain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m|\x1b\]133;[ABC]\x07/g, "")); + const content = plain.map((line) => line.trim()).filter((line) => line.length > 0); + + expect(lines.every((line) => !line.includes("\n"))).toBe(true); + expect(content).toEqual(['@"a', 'b"']); + expect(lines.join("\n")).toContain(theme.fg("success", '@"a')); + }); + test("does not highlight @ or -- without a whitespace boundary", () => { initTheme("dark"); const email = new UserMessageComponent("email me@example.com").render(60).join("\n"); @@ -156,4 +167,14 @@ describe("UserMessageComponent", () => { expect(plain.replace(/\s+/g, "")).toContain(`${command}界\uE000`); expect(lines.every((line) => visibleWidth(line) === 8)).toBe(true); }); + + test("renders a literal mask-range character before an @token uncorrupted", () => { + initTheme("dark"); + const plain = new UserMessageComponent("\uE000 check @foo") + .render(60) + .map((line) => line.replace(/\x1b\[[0-9;]*m|\x1b\]133;[ABC]\x07/g, "")) + .join("\n"); + + expect(plain).toContain("\uE000 check @foo"); + }); }); From f9081c51915ae25e423be1a718a6eec7c0877e8a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 10 Aug 2026 19:55:22 +0200 Subject: [PATCH 04/11] feat(coding-agent): highlight bare -- end-of-options separator in slash commands --- packages/coding-agent/CHANGELOG.md | 2 +- .../interactive/components/custom-editor.ts | 4 +++- .../components/prompt-highlight.ts | 18 +++++++++------ .../components/slash-command-message.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 2 +- .../coding-agent/test/custom-editor.test.ts | 22 +++++++++++++++++++ .../coding-agent/test/user-message.test.ts | 22 +++++++++++++++++++ 7 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index abb9e1cf20..a5e14dc51e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- Highlighted `@path` file references and `--flags` in the editor, queued message previews, and sent user messages. +- Highlighted `@path` file references and `--flags` in the editor, queued message previews, and sent user messages, plus the bare `--` end-of-options separator in recognized slash commands. - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index f83109a0d6..1d462e0f79 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -142,7 +142,9 @@ export class CustomEditor extends Editor { } override render(width: number): string[] { - this.argTokenHighlighter.reset(this.getLines()); + const commandMatch = /^(\s*)\/(\S+)/.exec(this.getLines()[0] ?? ""); + const isArgumentCommandLine = commandMatch !== null && this.isArgumentCommand(commandMatch[2]!); + this.argTokenHighlighter.reset(this.getLines(), isArgumentCommandLine); let lines = super.render(width); if (this.placeholder && this.getText().length === 0 && lines.length >= 2) { lines = [lines[0]!, this.renderPlaceholderLine(width), ...lines.slice(2)]; diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index d2c713a2a9..4a1cfd85ad 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -2,6 +2,8 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { type ThemeColor, theme } from "../theme/theme.js"; const ARG_TOKEN_PATTERN = /@"[^"\n]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*/g; +/** Also matches a bare `--` end-of-options separator; only used in recognized slash-command text. */ +const ARG_TOKEN_PATTERN_WITH_SEPARATOR = /@"[^"\n]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*|--(?=\s|$)/g; const FG_SGR_PATTERN = /\x1b\[(?:0|39|3[0-7]|9[0-7]|38;[0-9;]+)m/g; /** Escape sequences the editor splices into displayed text (cursor highlight, IME marker). */ const CURSOR_ESCAPE_PATTERN = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g; @@ -29,10 +31,11 @@ function hasTokenBoundary(text: string, index: number): boolean { return index === 0 || /\s/.test(text.charAt(index - 1)); } -/** Finds @path references and --flags in plain (unrendered) text. */ -function findArgTokens(text: string, fromIndex = 0): ArgTokenSpan[] { +/** Finds @path references and --flags in plain (unrendered) text; includeBareSeparator also matches a bare `--`. */ +function findArgTokens(text: string, fromIndex = 0, includeBareSeparator = false): ArgTokenSpan[] { const spans: ArgTokenSpan[] = []; - for (const match of text.matchAll(ARG_TOKEN_PATTERN)) { + const pattern = includeBareSeparator ? ARG_TOKEN_PATTERN_WITH_SEPARATOR : ARG_TOKEN_PATTERN; + for (const match of text.matchAll(pattern)) { if (match.index < fromIndex || !hasTokenBoundary(text, match.index)) continue; spans.push({ start: match.index, end: match.index + match[0].length, color: tokenColor(match[0]) }); } @@ -57,10 +60,11 @@ function maskGrapheme(grapheme: string): string { export function styleArgumentTokens( text: string, styleOther: (segment: string) => string = (segment) => segment, + includeBareSeparator = false, ): string { let result = ""; let offset = 0; - for (const token of findArgTokens(text)) { + for (const token of findArgTokens(text, 0, includeBareSeparator)) { result += styleOther(text.slice(offset, token.start)) + theme.fg(token.color, text.slice(token.start, token.end)); offset = token.end; } @@ -86,7 +90,7 @@ export class PromptTokenMask { if (commandEnd > 0) { tokens.push({ start: 0, end: commandEnd, color: "accent" }); } - tokens.push(...findArgTokens(source, commandEnd)); + tokens.push(...findArgTokens(source, commandEnd, commandEnd > 0)); let text = ""; let cursor = 0; @@ -148,8 +152,8 @@ export class PromptTokenMask { export class ArgTokenHighlighter { private spans: ArgTokenSpan[][] = []; - reset(lines: readonly string[]): void { - this.spans = lines.map((line) => findArgTokens(line)); + reset(lines: readonly string[], includeBareSeparator = false): void { + this.spans = lines.map((line) => findArgTokens(line, 0, includeBareSeparator)); } /** diff --git a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts index a580f75e62..f8817e27e8 100644 --- a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts @@ -14,7 +14,7 @@ export function isLeadingSlashCommand(text: string, isRecognized: (name: string) export function styleSlashCommandText( text: string, - styleRest: (rest: string) => string = (rest) => styleArgumentTokens(rest), + styleRest: (rest: string) => string = (rest) => styleArgumentTokens(rest, undefined, true), ): string { const parsed = parseSlashCommand(text); const commandEnd = parsed ? parsed.name.length + 1 : text.length; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8f0b4de0b5..5e97568841 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -313,7 +313,7 @@ export function styleQueuedMessagePreview( const styleDim = (segment: string) => theme.fg("dim", segment); if (!isLeadingSlashCommand(message, isRecognizedSlashCommand)) return styleArgumentTokens(preview, styleDim); const prefix = preview.slice(0, preview.length - message.length); - return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest) => styleArgumentTokens(rest, styleDim))}`; + return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest) => styleArgumentTokens(rest, styleDim, true))}`; } function isExpandable(obj: unknown): obj is Expandable { diff --git a/packages/coding-agent/test/custom-editor.test.ts b/packages/coding-agent/test/custom-editor.test.ts index 983b3c487d..4e6624d715 100644 --- a/packages/coding-agent/test/custom-editor.test.ts +++ b/packages/coding-agent/test/custom-editor.test.ts @@ -267,6 +267,28 @@ describe("CustomEditor", () => { expect(line).toContain(theme.fg("success", "@src/foo.ts")); }); + it("highlights a bare -- separator only for argument commands", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { + isArgumentCommand: (name) => name === "new", + }); + editor.setText("/new --name bla -- hello"); + + const commandLine = editor.render(60)[1]!; + + expect(commandLine).toContain(theme.fg("mdLink", "--")); + + editor.setText("this -- however -- is fine"); + const plain = editor.render(60).join("\n"); + + expect(plain).not.toContain(theme.fg("mdLink", "--")); + + editor.setText("/unknown -- hello"); + const unknownCommand = editor.render(60).join("\n"); + + expect(unknownCommand).not.toContain(theme.fg("mdLink", "--")); + }); + it("highlights wrapped @path fragments across editor lines", () => { initTheme("dark"); const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index bf73d9fda3..801b71bc3a 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -95,6 +95,28 @@ describe("UserMessageComponent", () => { expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); }); + test("highlights a bare -- separator only in recognized slash commands", () => { + initTheme("dark"); + const recognized = (name: string) => name === "new"; + const command = new UserMessageComponent("/new --name bla -- hello", undefined, recognized).render(60).join("\n"); + const plain = new UserMessageComponent("this -- however -- is fine", undefined, recognized).render(60).join("\n"); + + expect(command).toContain(theme.fg("mdLink", "--")); + expect(plain).not.toContain(theme.fg("mdLink", "--")); + }); + + test("does not highlight --- or glued -- as a separator", () => { + initTheme("dark"); + const recognized = (name: string) => name === "new"; + const triple = new UserMessageComponent("/new a --- b", undefined, recognized).render(60).join("\n"); + const glued = new UserMessageComponent("/new x-- y", undefined, recognized).render(60).join("\n"); + const plain = new UserMessageComponent("a --- b").render(60).join("\n"); + + expect(triple).not.toContain(theme.fg("mdLink", "--")); + expect(glued).not.toContain(theme.fg("mdLink", "--")); + expect(plain).not.toContain(theme.fg("mdLink", "--")); + }); + test("highlights @path references in plain user messages", () => { initTheme("dark"); const rendered = new UserMessageComponent("check @src/foo.ts please").render(60).join("\n"); From 66359df8a7d01f8e0818c493945a6c65247e0795 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 13:34:34 +0200 Subject: [PATCH 05/11] fix(coding-agent): keep arg-token color past the cursor reset mid-token --- .../interactive/components/prompt-highlight.ts | 9 ++++++++- packages/coding-agent/test/custom-editor.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index 4a1cfd85ad..0541ba4c97 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -190,7 +190,14 @@ export class ArgTokenHighlighter { for (const span of spans) { const start = visibleStart[span.start] ?? displayText.length; const end = (visibleStart[span.end - 1] ?? displayText.length - 1) + 1; - result += displayText.slice(copied, start) + theme.fg(span.color, displayText.slice(start, end)); + // The cursor splice may carry a full reset (\x1b[0m) mid-span; wrap + // each segment so the token color survives past it. + const styled = displayText + .slice(start, end) + .split("\x1b[0m") + .map((segment) => theme.fg(span.color, segment)) + .join("\x1b[0m"); + result += displayText.slice(copied, start) + styled; copied = end; } return result + displayText.slice(copied); diff --git a/packages/coding-agent/test/custom-editor.test.ts b/packages/coding-agent/test/custom-editor.test.ts index deb44f6ae8..afb695162e 100644 --- a/packages/coding-agent/test/custom-editor.test.ts +++ b/packages/coding-agent/test/custom-editor.test.ts @@ -348,6 +348,19 @@ describe("CustomEditor", () => { expect(rendered).not.toContain(theme.fg("success", "foo")); }); + it("keeps the token tail colored when the cursor sits inside the token", () => { + initTheme("dark"); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); + editor.setText("check @src/foo.ts"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); + + const line = editor.render(40)[1]!; + + // The cursor's full reset sits before the final "s"; the tail must be re-colored. + expect(line).toContain(`\x1b[0m${theme.fg("success", "s")}`); + }); + it("does not mis-color visible text matching a scrolled-away token", () => { initTheme("dark"); const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); From 8435a49a647e1f1a2bceacc2664e7d2044ba5218 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 13:34:40 +0200 Subject: [PATCH 06/11] refactor(coding-agent): dedupe the editor command-token pattern --- .../src/modes/interactive/components/custom-editor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 6e3ebb8d40..12d4d8ea95 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -10,6 +10,8 @@ import { import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js"; import { ArgTokenHighlighter } from "./prompt-highlight.js"; +const COMMAND_TOKEN_PATTERN = /^(\s*)\/(\S+)/; + export interface CustomEditorOptions extends EditorOptions { placeholder?: string; placeholderColor?: (text: string) => string; @@ -94,7 +96,7 @@ export class CustomEditor extends Editor { return displayText; } - const match = /^(\s*)\/(\S+)/.exec(lineText); + const match = COMMAND_TOKEN_PATTERN.exec(lineText); if (!match) { return displayText; } @@ -142,7 +144,7 @@ export class CustomEditor extends Editor { } override render(width: number): string[] { - const commandMatch = /^(\s*)\/(\S+)/.exec(this.getLines()[0] ?? ""); + const commandMatch = COMMAND_TOKEN_PATTERN.exec(this.getLines()[0] ?? ""); const isArgumentCommandLine = commandMatch !== null && this.isArgumentCommand(commandMatch[2]!); this.argTokenHighlighter.reset(this.getLines(), isArgumentCommandLine); let lines = super.render(width); From aac41f2f41b84e5b4e91b4eb0f199ef1a8d3871b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 14:00:25 +0200 Subject: [PATCH 07/11] fix(coding-agent): forward table-cell selection regions through HighlightedMarkdown --- .../modes/interactive/components/user-message.ts | 13 ++++++++++++- packages/coding-agent/test/user-message.test.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index c41218f931..736f2b5286 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -1,4 +1,11 @@ -import { Box, type Component, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; +import { + Box, + type Component, + Container, + Markdown, + type MarkdownTheme, + type TableCellSelectionRegion, +} from "@earendil-works/pi-tui"; import { parseSlashCommand } from "../../../core/slash-commands.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; import { PromptTokenMask } from "./prompt-highlight.js"; @@ -23,6 +30,10 @@ class HighlightedMarkdown implements Component { return this.markdown.render(width).map((line) => this.mask.restoreLine(line)); } + getSelectionRegions(): ReadonlyArray { + return this.markdown.getSelectionRegions(); + } + invalidate(): void { this.markdown.invalidate(); } diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 801b71bc3a..6f78bd136a 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -190,6 +190,16 @@ describe("UserMessageComponent", () => { expect(lines.every((line) => visibleWidth(line) === 8)).toBe(true); }); + test("forwards table-cell selection regions from sent messages", () => { + initTheme("dark"); + const component = new UserMessageComponent("| alpha | beta |\n| --- | --- |\n| one | two |"); + component.render(60); + + const regions = component.getSelectionRegions(); + + expect(regions.map((region) => region.content)).toContain("one"); + }); + test("renders a literal mask-range character before an @token uncorrupted", () => { initTheme("dark"); const plain = new UserMessageComponent("\uE000 check @foo") From 3e0e277429d79dc15aa0e3e31419afb55e14ddfe Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 15:22:12 +0200 Subject: [PATCH 08/11] fix(coding-agent): identity-based mask placeholders so copied table cells keep literal token text --- .../components/prompt-highlight.ts | 46 +++++++++++-------- .../interactive/components/user-message.ts | 6 ++- .../coding-agent/test/user-message.test.ts | 10 ++-- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index 0541ba4c97..7d82251044 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -8,12 +8,13 @@ const FG_SGR_PATTERN = /\x1b\[(?:0|39|3[0-7]|9[0-7]|38;[0-9;]+)m/g; /** Escape sequences the editor splices into displayed text (cursor highlight, IME marker). */ const CURSOR_ESCAPE_PATTERN = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g; -const MASK_BASE = "\uE000"; +const MASK_BASE_START = 0xe000; +/** Each masked grapheme gets its own private-use base char, so restoring is a lookup, not positional. */ +const MASK_CAPACITY = 0xf8ff - MASK_BASE_START + 1; const MASK_EXTRA_WIDTH = "\uFF9E"; -const MASK_ZERO_WIDTH = "\u2060"; -const MASK_PATTERN = /\u2060|\uE000\uFF9E*/gu; +const MASK_PATTERN = /[\uE000-\uF8FF]\uFF9E*/gu; /** Literal mask-range characters would alias generated placeholders; messages containing them skip masking. */ -const MASK_LITERAL_PATTERN = /[\u2060\uE000\uFF9E]/u; +const MASK_LITERAL_PATTERN = /[\uE000-\uF8FF\uFF9E]/u; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); @@ -51,11 +52,6 @@ function activeFgBefore(line: string, index: number): string { return active; } -function maskGrapheme(grapheme: string): string { - const width = visibleWidth(grapheme); - return width === 0 ? MASK_ZERO_WIDTH : MASK_BASE + MASK_EXTRA_WIDTH.repeat(width - 1); -} - /** Styles @path references and --flags in a plain (unrendered) string. */ export function styleArgumentTokens( text: string, @@ -78,8 +74,7 @@ export function styleArgumentTokens( */ export class PromptTokenMask { readonly text: string; - private readonly graphemes: { segment: string; color: ThemeColor }[] = []; - private offset = 0; + private graphemes: { segment: string; color: ThemeColor }[] = []; constructor(source: string, commandEnd = 0) { if (MASK_LITERAL_PATTERN.test(source)) { @@ -97,17 +92,33 @@ export class PromptTokenMask { for (const token of tokens) { text += source.slice(cursor, token.start); for (const { segment } of graphemeSegmenter.segment(source.slice(token.start, token.end))) { + const width = visibleWidth(segment); + if (width === 0) { + // Zero-width graphemes stay literal: invisible either way, and + // leaving them keeps extracted text (cell content) exact. + text += segment; + continue; + } + if (this.graphemes.length === MASK_CAPACITY) { + this.text = source; + this.graphemes = []; + return; + } + text += String.fromCharCode(MASK_BASE_START + this.graphemes.length) + MASK_EXTRA_WIDTH.repeat(width - 1); this.graphemes.push({ segment, color: token.color }); - text += maskGrapheme(segment); } cursor = token.end; } this.text = text + source.slice(cursor); } - /** Placeholders are consumed in order across lines; rewind before each render pass. */ - reset(): void { - this.offset = 0; + private graphemeFor(placeholder: string): { segment: string; color: ThemeColor } | undefined { + return this.graphemes[placeholder.charCodeAt(0) - MASK_BASE_START]; + } + + /** Restores masked graphemes in text extracted from a render, e.g. selection-region cell content. */ + restoreText(text: string): string { + return text.replace(MASK_PATTERN, (placeholder) => this.graphemeFor(placeholder)?.segment ?? placeholder); } restoreLine(line: string): string { @@ -121,9 +132,8 @@ export class PromptTokenMask { run = undefined; }; for (const match of line.matchAll(MASK_PATTERN)) { - const grapheme = this.graphemes[this.offset]; - if (!grapheme) break; // literal mask-range character from the source; leave it untouched - this.offset++; + const grapheme = this.graphemeFor(match[0]); + if (!grapheme) continue; // literal mask-range character from an unmasked source; leave it untouched if (run && run.color === grapheme.color && run.end === match.index) { run.text += grapheme.segment; run.end += match[0].length; diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 736f2b5286..68373ec807 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -26,12 +26,14 @@ class HighlightedMarkdown implements Component { } render(width: number): string[] { - this.mask.reset(); return this.markdown.render(width).map((line) => this.mask.restoreLine(line)); } getSelectionRegions(): ReadonlyArray { - return this.markdown.getSelectionRegions(); + return this.markdown.getSelectionRegions().map((region) => ({ + ...region, + content: this.mask.restoreText(region.content), + })); } invalidate(): void { diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 6f78bd136a..1f1013b561 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -190,14 +190,16 @@ describe("UserMessageComponent", () => { expect(lines.every((line) => visibleWidth(line) === 8)).toBe(true); }); - test("forwards table-cell selection regions from sent messages", () => { + test("forwards table-cell selection regions with unmasked content", () => { initTheme("dark"); - const component = new UserMessageComponent("| alpha | beta |\n| --- | --- |\n| one | two |"); + const component = new UserMessageComponent("| alpha | beta |\n| --- | --- |\n| @src/a.ts | two |"); component.render(60); - const regions = component.getSelectionRegions(); + const contents = component.getSelectionRegions().map((region) => region.content); - expect(regions.map((region) => region.content)).toContain("one"); + // Copying the token cell must yield its literal text, not mask placeholders. + expect(contents).toContain("@src/a.ts"); + expect(contents).toContain("two"); }); test("renders a literal mask-range character before an @token uncorrupted", () => { From 8e6790d976b612372ee8df18d5a3752fbbf92608 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 16:11:08 +0200 Subject: [PATCH 09/11] fix(coding-agent): gate the bare -- separator on argument-taking commands across all surfaces --- .../modes/interactive/components/prompt-highlight.ts | 4 ++-- .../interactive/components/slash-command-message.ts | 10 +++++++--- .../src/modes/interactive/components/user-message.ts | 10 ++++++---- .../src/modes/interactive/interactive-mode.ts | 4 +++- packages/coding-agent/test/user-message.test.ts | 8 ++++++-- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index 7d82251044..13a851c5f9 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -76,7 +76,7 @@ export class PromptTokenMask { readonly text: string; private graphemes: { segment: string; color: ThemeColor }[] = []; - constructor(source: string, commandEnd = 0) { + constructor(source: string, commandEnd = 0, includeBareSeparator = false) { if (MASK_LITERAL_PATTERN.test(source)) { this.text = source; return; @@ -85,7 +85,7 @@ export class PromptTokenMask { if (commandEnd > 0) { tokens.push({ start: 0, end: commandEnd, color: "accent" }); } - tokens.push(...findArgTokens(source, commandEnd, commandEnd > 0)); + tokens.push(...findArgTokens(source, commandEnd, includeBareSeparator)); let text = ""; let cursor = 0; diff --git a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts index f8817e27e8..786566173a 100644 --- a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts @@ -1,5 +1,5 @@ import { Box, Container, Text } from "@earendil-works/pi-tui"; -import { parseSlashCommand } from "../../../core/slash-commands.js"; +import { builtinSlashCommandTakesArgument, parseSlashCommand } from "../../../core/slash-commands.js"; import { theme } from "../theme/theme.js"; import { styleArgumentTokens } from "./prompt-highlight.js"; @@ -14,11 +14,15 @@ export function isLeadingSlashCommand(text: string, isRecognized: (name: string) export function styleSlashCommandText( text: string, - styleRest: (rest: string) => string = (rest) => styleArgumentTokens(rest, undefined, true), + styleRest: (rest: string, includeBareSeparator: boolean) => string = (rest, includeBareSeparator) => + styleArgumentTokens(rest, undefined, includeBareSeparator), ): string { const parsed = parseSlashCommand(text); const commandEnd = parsed ? parsed.name.length + 1 : text.length; - return `${theme.fg("accent", text.slice(0, commandEnd))}${styleRest(text.slice(commandEnd))}`; + // The bare -- separator is only meaningful in commands that take arguments, + // matching the editor's isArgumentCommand gate. + const includeBareSeparator = parsed !== undefined && builtinSlashCommandTakesArgument(parsed.name); + return `${theme.fg("accent", text.slice(0, commandEnd))}${styleRest(text.slice(commandEnd), includeBareSeparator)}`; } /** Renders a durable session command with the same layout as a user message. */ diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 68373ec807..581de174d0 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -6,7 +6,7 @@ import { type MarkdownTheme, type TableCellSelectionRegion, } from "@earendil-works/pi-tui"; -import { parseSlashCommand } from "../../../core/slash-commands.js"; +import { builtinSlashCommandTakesArgument, parseSlashCommand } from "../../../core/slash-commands.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; import { PromptTokenMask } from "./prompt-highlight.js"; @@ -18,8 +18,8 @@ class HighlightedMarkdown implements Component { private readonly markdown: Markdown; private readonly mask: PromptTokenMask; - constructor(text: string, markdownTheme: MarkdownTheme, commandEnd = 0) { - this.mask = new PromptTokenMask(text, commandEnd); + constructor(text: string, markdownTheme: MarkdownTheme, commandEnd = 0, includeBareSeparator = false) { + this.mask = new PromptTokenMask(text, commandEnd, includeBareSeparator); this.markdown = new Markdown(this.mask.text, 0, 0, markdownTheme, { color: (content: string) => theme.fg("userMessageText", content), }); @@ -52,8 +52,10 @@ export class UserMessageComponent extends Container { super(); const command = parseSlashCommand(text); const commandEnd = command && isRecognizedSlashCommand(command.name) ? command.name.length + 1 : 0; + const includeBareSeparator = + command !== undefined && commandEnd > 0 && builtinSlashCommandTakesArgument(command.name); this.contentBox = new Box(2, 1, (content: string) => theme.getUserMessageBackgroundColor()(content)); - this.contentBox.addChild(new HighlightedMarkdown(text, markdownTheme, commandEnd)); + this.contentBox.addChild(new HighlightedMarkdown(text, markdownTheme, commandEnd, includeBareSeparator)); this.addChild(this.contentBox); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 31e3cae3fe..ca496d644d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -328,7 +328,9 @@ export function styleQueuedMessagePreview( const styleDim = (segment: string) => theme.fg("dim", segment); if (!isLeadingSlashCommand(message, isRecognizedSlashCommand)) return styleArgumentTokens(preview, styleDim); const prefix = preview.slice(0, preview.length - message.length); - return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest) => styleArgumentTokens(rest, styleDim, true))}`; + return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest, includeBareSeparator) => + styleArgumentTokens(rest, styleDim, includeBareSeparator), + )}`; } function isExpandable(obj: unknown): obj is Expandable { diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 1f1013b561..1737535b0e 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -95,13 +95,17 @@ describe("UserMessageComponent", () => { expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); }); - test("highlights a bare -- separator only in recognized slash commands", () => { + test("highlights a bare -- separator only in argument-taking slash commands", () => { initTheme("dark"); - const recognized = (name: string) => name === "new"; + const recognized = (name: string) => name === "new" || name === "compact"; const command = new UserMessageComponent("/new --name bla -- hello", undefined, recognized).render(60).join("\n"); + // /compact is recognized but takes no argument; the editor shows no + // separator there, so the sent message must match. + const noArgument = new UserMessageComponent("/compact -- hello", undefined, recognized).render(60).join("\n"); const plain = new UserMessageComponent("this -- however -- is fine", undefined, recognized).render(60).join("\n"); expect(command).toContain(theme.fg("mdLink", "--")); + expect(noArgument).not.toContain(theme.fg("mdLink", "--")); expect(plain).not.toContain(theme.fg("mdLink", "--")); }); From f08133faee233a0f6ebc9b6473605cdfc02301fd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 16:11:32 +0200 Subject: [PATCH 10/11] fix(coding-agent): normalize tabs before token masking to match Markdown layout --- .../src/modes/interactive/components/prompt-highlight.ts | 3 +++ packages/coding-agent/test/user-message.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index 13a851c5f9..e7c586763f 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -77,6 +77,9 @@ export class PromptTokenMask { private graphemes: { segment: string; color: ThemeColor }[] = []; constructor(source: string, commandEnd = 0, includeBareSeparator = false) { + // Markdown normalizes tabs to three spaces; masking a raw tab would + // restore it into a layout computed for three columns, so normalize first. + source = source.replace(/\t/g, " "); if (MASK_LITERAL_PATTERN.test(source)) { this.text = source; return; diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 1737535b0e..a6078ca960 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -175,6 +175,14 @@ describe("UserMessageComponent", () => { expect(dashes).not.toContain(theme.fg("mdLink", "--b")); }); + test("normalizes tabs inside quoted @paths like Markdown does", () => { + initTheme("dark"); + const lines = new UserMessageComponent('open @"a\tb.txt" now').render(30); + + expect(lines.some((line) => line.includes("\t"))).toBe(false); + expect(lines.join("\n")).toContain(theme.fg("success", '@"a b.txt"')); + }); + test("styleArgumentTokens highlights quoted @paths", () => { initTheme("dark"); expect(styleArgumentTokens('open @"a b.txt" now')).toBe(`open ${theme.fg("success", '@"a b.txt"')} now`); From 285ccf457c2f94508b5764d0ab1cc1b4b79a6d61 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 18:55:57 +0200 Subject: [PATCH 11/11] refactor(coding-agent): condense highlight comments and table-drive the token pins --- .../interactive/components/custom-editor.ts | 3 +- .../components/prompt-highlight.ts | 36 ++---- .../components/slash-command-message.ts | 3 +- .../coding-agent/test/custom-editor.test.ts | 119 ++++++------------ .../coding-agent/test/user-message.test.ts | 117 +++++++---------- 5 files changed, 96 insertions(+), 182 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 12d4d8ea95..ee505f92b0 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -79,8 +79,7 @@ export class CustomEditor extends Editor { if (sourceLine === undefined || sourceStart === undefined || this.getBashPromptInfo(this.getLines()[0] ?? "")) { return this.styleCommandToken(displayText, layoutLineIndex, lineText, cursorCol); } - // Arg tokens are styled first: their spans start after the command - // token, so the command offsets below stay valid. + // Arg tokens are styled first; their spans start after the command token, so the command offsets stay valid. const highlighted = this.argTokenHighlighter.highlightLine(displayText, lineText, sourceLine, sourceStart); return this.styleCommandToken(highlighted, layoutLineIndex, lineText, cursorCol); } diff --git a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts index e7c586763f..ee0b9e1809 100644 --- a/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts +++ b/packages/coding-agent/src/modes/interactive/components/prompt-highlight.ts @@ -2,7 +2,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { type ThemeColor, theme } from "../theme/theme.js"; const ARG_TOKEN_PATTERN = /@"[^"\n]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*/g; -/** Also matches a bare `--` end-of-options separator; only used in recognized slash-command text. */ +/** Also matches a bare `--` end-of-options separator; only used for argument-taking slash commands. */ const ARG_TOKEN_PATTERN_WITH_SEPARATOR = /@"[^"\n]*"|@[^\s\x1b]+|--[A-Za-z0-9][A-Za-z0-9-]*|--(?=\s|$)/g; const FG_SGR_PATTERN = /\x1b\[(?:0|39|3[0-7]|9[0-7]|38;[0-9;]+)m/g; /** Escape sequences the editor splices into displayed text (cursor highlight, IME marker). */ @@ -32,7 +32,6 @@ function hasTokenBoundary(text: string, index: number): boolean { return index === 0 || /\s/.test(text.charAt(index - 1)); } -/** Finds @path references and --flags in plain (unrendered) text; includeBareSeparator also matches a bare `--`. */ function findArgTokens(text: string, fromIndex = 0, includeBareSeparator = false): ArgTokenSpan[] { const spans: ArgTokenSpan[] = []; const pattern = includeBareSeparator ? ARG_TOKEN_PATTERN_WITH_SEPARATOR : ARG_TOKEN_PATTERN; @@ -52,7 +51,6 @@ function activeFgBefore(line: string, index: number): string { return active; } -/** Styles @path references and --flags in a plain (unrendered) string. */ export function styleArgumentTokens( text: string, styleOther: (segment: string) => string = (segment) => segment, @@ -67,18 +65,13 @@ export function styleArgumentTokens( return result + styleOther(text.slice(offset)); } -/** - * Replaces the leading slash command (optional) and every @path/--flag token - * in a prompt with same-width placeholders before markdown layout, so the - * themed text can be spliced back into rendered lines after wrapping. - */ +/** Masks the slash command and @path/--flag tokens with same-width placeholders before markdown layout. */ export class PromptTokenMask { readonly text: string; private graphemes: { segment: string; color: ThemeColor }[] = []; constructor(source: string, commandEnd = 0, includeBareSeparator = false) { - // Markdown normalizes tabs to three spaces; masking a raw tab would - // restore it into a layout computed for three columns, so normalize first. + // Markdown turns tabs into three spaces; a masked raw tab would be restored into a three-column layout. source = source.replace(/\t/g, " "); if (MASK_LITERAL_PATTERN.test(source)) { this.text = source; @@ -97,8 +90,7 @@ export class PromptTokenMask { for (const { segment } of graphemeSegmenter.segment(source.slice(token.start, token.end))) { const width = visibleWidth(segment); if (width === 0) { - // Zero-width graphemes stay literal: invisible either way, and - // leaving them keeps extracted text (cell content) exact. + // Zero-width graphemes stay literal: invisible either way, and extracted text stays exact. text += segment; continue; } @@ -155,13 +147,7 @@ export class PromptTokenMask { } } -/** - * Styles @path references and --flags in laid-out editor lines. Token spans - * are computed on the logical source lines before wrapping, so wrapped - * fragments, quoted paths with spaces, and line-leading tokens are all - * colored exactly. Call reset() with the current lines before each render - * pass; each chunk carries its exact source coordinates. - */ +/** Styles tokens in laid-out editor lines from spans on the logical source lines; reset() before each render pass. */ export class ArgTokenHighlighter { private spans: ArgTokenSpan[][] = []; @@ -169,11 +155,7 @@ export class ArgTokenHighlighter { this.spans = lines.map((line) => findArgTokens(line, 0, includeBareSeparator)); } - /** - * Styles the tokens covered by one laid-out chunk. displayText is the - * chunk with any cursor escape sequences already spliced in; chunkText is - * the raw chunk text starting at sourceStart within source line sourceLine. - */ + /** displayText is chunkText with cursor escapes spliced in; chunkText starts at sourceStart within sourceLine. */ highlightLine(displayText: string, chunkText: string, sourceLine: number, sourceStart: number): string { const rangeEnd = sourceStart + chunkText.length; const spans: ArgTokenSpan[] = []; @@ -188,8 +170,7 @@ export class ArgTokenHighlighter { } if (spans.length === 0) return displayText; - // Map visible code-unit offsets to displayText offsets, skipping the - // escape sequences the editor spliced in for the cursor. + // Maps visible code-unit offsets to displayText offsets, skipping the editor's cursor escapes. const visibleStart: number[] = []; let pos = 0; for (const seq of displayText.matchAll(CURSOR_ESCAPE_PATTERN)) { @@ -203,8 +184,7 @@ export class ArgTokenHighlighter { for (const span of spans) { const start = visibleStart[span.start] ?? displayText.length; const end = (visibleStart[span.end - 1] ?? displayText.length - 1) + 1; - // The cursor splice may carry a full reset (\x1b[0m) mid-span; wrap - // each segment so the token color survives past it. + // The cursor splice may carry a full reset mid-span; wrap each segment so the token color survives it. const styled = displayText .slice(start, end) .split("\x1b[0m") diff --git a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts index 786566173a..1c58af8e83 100644 --- a/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/slash-command-message.ts @@ -19,8 +19,7 @@ export function styleSlashCommandText( ): string { const parsed = parseSlashCommand(text); const commandEnd = parsed ? parsed.name.length + 1 : text.length; - // The bare -- separator is only meaningful in commands that take arguments, - // matching the editor's isArgumentCommand gate. + // Matches the editor's gate: a bare -- is only meaningful in commands that take arguments. const includeBareSeparator = parsed !== undefined && builtinSlashCommandTakesArgument(parsed.name); return `${theme.fg("accent", text.slice(0, commandEnd))}${styleRest(text.slice(commandEnd), includeBareSeparator)}`; } diff --git a/packages/coding-agent/test/custom-editor.test.ts b/packages/coding-agent/test/custom-editor.test.ts index afb695162e..37ee3e034e 100644 --- a/packages/coding-agent/test/custom-editor.test.ts +++ b/packages/coding-agent/test/custom-editor.test.ts @@ -3,7 +3,7 @@ import { CURSOR_MARKER, setKeybindings, visibleWidth } from "@earendil-works/pi- import { beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.js"; import { CustomEditor } from "../src/modes/interactive/components/custom-editor.js"; -import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; +import { initTheme, type ThemeColor, theme } from "../src/modes/interactive/theme/theme.js"; const passthrough = (text: string) => text; @@ -280,99 +280,62 @@ describe("CustomEditor", () => { } }); - it("highlights @path references and --flags in the input text", () => { + const makeHighlightEditor = (text: string, options?: { isArgumentCommand?: (name: string) => boolean }) => { initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - editor.setText("/new --name foo @src/foo.ts"); - - const line = editor.render(40)[1]!; - - expect(line).toContain(theme.fg("mdLink", "--name")); - expect(line).toContain(theme.fg("success", "@src/foo.ts")); + const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), options); + editor.setText(text); + return editor; + }; + + it.each<{ name: string; text: string; width: number; color: ThemeColor; has?: string[]; lacks?: string[] }>([ + { name: "--flags in the input text", text: "/new --name @a.ts", width: 40, color: "mdLink", has: ["--name"] }, + { name: "@paths in the input text", text: "/new --name @a.ts", width: 40, color: "success", has: ["@a.ts"] }, + { + name: "wrapped @path fragments across editor lines", + text: "check @src/very-long-file-name.ts please", + width: 16, + color: "success", + has: ["@src/very-lon", "g-file-name.t", "s"], + }, + { + name: "quoted @paths across wrapped editor lines", + text: 'open @"docs/some very long name.txt" now', + width: 16, + color: "success", + has: ['@"docs/some ', "very long ", 'name.txt"'], + }, + { name: "no line bleed", text: "@abcde\nfoo bar", width: 11, color: "success", has: ["@abcde"], lacks: ["foo"] }, + ])("highlights $name", ({ text, width, color, has, lacks }) => { + const rendered = makeHighlightEditor(text).render(width).join("\n"); + + for (const fragment of has ?? []) expect(rendered).toContain(theme.fg(color, fragment)); + for (const fragment of lacks ?? []) expect(rendered).not.toContain(theme.fg(color, fragment)); }); it("highlights a bare -- separator only for argument commands", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { - isArgumentCommand: (name) => name === "new", - }); - editor.setText("/new --name bla -- hello"); - - const commandLine = editor.render(60)[1]!; - - expect(commandLine).toContain(theme.fg("mdLink", "--")); - - editor.setText("this -- however -- is fine"); - const plain = editor.render(60).join("\n"); - - expect(plain).not.toContain(theme.fg("mdLink", "--")); - - editor.setText("/unknown -- hello"); - const unknownCommand = editor.render(60).join("\n"); - - expect(unknownCommand).not.toContain(theme.fg("mdLink", "--")); - }); - - it("highlights wrapped @path fragments across editor lines", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - editor.setText("check @src/very-long-file-name.ts please"); - - const rendered = editor.render(16).join("\n"); - - expect(rendered).toContain(theme.fg("success", "@src/very-lon")); - expect(rendered).toContain(theme.fg("success", "g-file-name.t")); - expect(rendered).toContain(theme.fg("success", "s")); - }); - - it("keeps quoted @paths highlighted across wrapped editor lines", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - editor.setText('open @"docs/some very long name.txt" now'); - - const rendered = editor.render(16).join("\n"); + const options = { isArgumentCommand: (name: string) => name === "new" }; + const separator = theme.fg("mdLink", "--"); - expect(rendered).toContain(theme.fg("success", '@"docs/some ')); - expect(rendered).toContain(theme.fg("success", "very long ")); - expect(rendered).toContain(theme.fg("success", 'name.txt"')); + expect(makeHighlightEditor("/new --name bla -- hello", options).render(60).join("\n")).toContain(separator); + expect(makeHighlightEditor("this -- however -- is fine", options).render(60).join("\n")).not.toContain(separator); + expect(makeHighlightEditor("/unknown -- hello", options).render(60).join("\n")).not.toContain(separator); }); - it("does not bleed @path highlighting onto the next line", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - editor.setText("@abcde\nfoo bar"); - - const rendered = editor.render(11).join("\n"); + it("does not mis-color visible text matching a scrolled-away token", () => { + // 9 lines with the cursor at the end scroll @foo out of view; the visible plain "foo" must stay uncolored. + const rendered = makeHighlightEditor("@foo\nhidden\nfoo\nl3\nl4\nl5\nl6\nl7\nl8").render(20).join("\n"); - expect(rendered).toContain(theme.fg("success", "@abcde")); + expect(rendered).toContain("↑ 2 more"); expect(rendered).not.toContain(theme.fg("success", "foo")); }); it("keeps the token tail colored when the cursor sits inside the token", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - editor.setText("check @src/foo.ts"); + const editor = makeHighlightEditor("check @src/foo.ts"); editor.handleInput("\x1b[D"); editor.handleInput("\x1b[D"); - const line = editor.render(40)[1]!; - // The cursor's full reset sits before the final "s"; the tail must be re-colored. - expect(line).toContain(`\x1b[0m${theme.fg("success", "s")}`); - }); - - it("does not mis-color visible text matching a scrolled-away token", () => { - initTheme("dark"); - const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); - // 9 logical lines with the cursor at the end scroll the first two - // (including the @foo token) out of view; the visible plain "foo" - // must not inherit the hidden token's color. - editor.setText("@foo\nhidden\nfoo\nl3\nl4\nl5\nl6\nl7\nl8"); - - const rendered = editor.render(20).join("\n"); - - expect(rendered).toContain("↑ 2 more"); - expect(rendered).not.toContain(theme.fg("success", "foo")); + expect(editor.render(40)[1]!).toContain(`\x1b[0m${theme.fg("success", "s")}`); }); it("renders no header when the callback returns undefined", () => { diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index a6078ca960..d1dec3a229 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -2,7 +2,7 @@ import { clearDefaultTerminalColors, setDefaultTerminalColors, visibleWidth } fr import { afterEach, describe, expect, test } from "vitest"; import { styleArgumentTokens } from "../src/modes/interactive/components/prompt-highlight.js"; import { UserMessageComponent } from "../src/modes/interactive/components/user-message.js"; -import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; +import { initTheme, type ThemeColor, theme } from "../src/modes/interactive/theme/theme.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; @@ -84,75 +84,57 @@ describe("UserMessageComponent", () => { expect(plainLines).toEqual(expectedLines); }); - test("highlights flags and @path references in slash command arguments", () => { + const renderMessage = (text: string, width = 60, recognized: (name: string) => boolean = () => false) => { initTheme("dark"); - const rendered = new UserMessageComponent("/new --name foo @src/foo.ts", undefined, (name) => name === "new") - .render(60) - .join("\n"); - - expect(rendered).toContain(theme.fg("accent", "/new")); - expect(rendered).toContain(theme.fg("mdLink", "--name")); - expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); - }); - - test("highlights a bare -- separator only in argument-taking slash commands", () => { - initTheme("dark"); - const recognized = (name: string) => name === "new" || name === "compact"; - const command = new UserMessageComponent("/new --name bla -- hello", undefined, recognized).render(60).join("\n"); - // /compact is recognized but takes no argument; the editor shows no - // separator there, so the sent message must match. - const noArgument = new UserMessageComponent("/compact -- hello", undefined, recognized).render(60).join("\n"); - const plain = new UserMessageComponent("this -- however -- is fine", undefined, recognized).render(60).join("\n"); - - expect(command).toContain(theme.fg("mdLink", "--")); - expect(noArgument).not.toContain(theme.fg("mdLink", "--")); - expect(plain).not.toContain(theme.fg("mdLink", "--")); - }); - - test("does not highlight --- or glued -- as a separator", () => { - initTheme("dark"); - const recognized = (name: string) => name === "new"; - const triple = new UserMessageComponent("/new a --- b", undefined, recognized).render(60).join("\n"); - const glued = new UserMessageComponent("/new x-- y", undefined, recognized).render(60).join("\n"); - const plain = new UserMessageComponent("a --- b").render(60).join("\n"); - - expect(triple).not.toContain(theme.fg("mdLink", "--")); - expect(glued).not.toContain(theme.fg("mdLink", "--")); - expect(plain).not.toContain(theme.fg("mdLink", "--")); - }); + return new UserMessageComponent(text, undefined, recognized).render(width).join("\n"); + }; - test("highlights @path references in plain user messages", () => { - initTheme("dark"); - const rendered = new UserMessageComponent("check @src/foo.ts please").render(60).join("\n"); - - expect(rendered).toContain(theme.fg("success", "@src/foo.ts")); - }); + const commandText = "/new --name foo @src/foo.ts"; - test("highlights leading and newline-leading @path references in plain user messages", () => { - initTheme("dark"); - const leading = new UserMessageComponent("@src/foo.ts please").render(60).join("\n"); - const newline = new UserMessageComponent("hello\n@foo").render(60).join("\n"); - - expect(leading).toContain(theme.fg("success", "@src/foo.ts")); - expect(newline).toContain(theme.fg("success", "@foo")); - }); - - test("highlights every wrapped fragment of a long @path", () => { - initTheme("dark"); - const rendered = new UserMessageComponent("check @src/very-long-file-name.ts please").render(16).join("\n"); + test.each<{ name: string; text: string; width?: number; color: ThemeColor; has?: string[]; lacks?: string[] }>([ + { name: "the command accent", text: commandText, color: "accent", has: ["/new"] }, + { name: "--flags in command arguments", text: commandText, color: "mdLink", has: ["--name"] }, + { name: "@paths in command arguments", text: commandText, color: "success", has: ["@src/foo.ts"] }, + { name: "@paths in plain messages", text: "check @src/foo.ts please", color: "success", has: ["@src/foo.ts"] }, + { name: "a leading @path", text: "@src/foo.ts please", color: "success", has: ["@src/foo.ts"] }, + { name: "a newline-leading @path", text: "hello\n@foo", color: "success", has: ["@foo"] }, + { + name: "every wrapped fragment of a long @path", + text: "check @src/very-long-file-name.ts please", + width: 16, + color: "success", + has: ["@src/very-lo", "ng-file-name", ".ts"], + }, + { + name: "quoted @paths across narrow wraps", + text: 'open @"docs/some very long name.txt" now', + width: 16, + color: "success", + has: ['@"docs/some ', "very long na", 'me.txt"'], + }, + { name: "no mid-word @ (emails)", text: "email me@example.com", color: "success", lacks: ["@example.com"] }, + { name: "no glued dashes", text: "a---b", color: "mdLink", lacks: ["--b"] }, + ])("styles tokens: $name", ({ text, width, color, has, lacks }) => { + const rendered = renderMessage(text, width, (name) => name === "new"); - expect(rendered).toContain(theme.fg("success", "@src/very-lo")); - expect(rendered).toContain(theme.fg("success", "ng-file-name")); - expect(rendered).toContain(theme.fg("success", ".ts")); + for (const fragment of has ?? []) expect(rendered).toContain(theme.fg(color, fragment)); + for (const fragment of lacks ?? []) expect(rendered).not.toContain(theme.fg(color, fragment)); }); - test("keeps quoted @paths highlighted across narrow wraps", () => { - initTheme("dark"); - const rendered = new UserMessageComponent('open @"docs/some very long name.txt" now').render(16).join("\n"); + test("highlights a bare -- separator only in argument-taking slash commands", () => { + const recognized = (name: string) => name === "new" || name === "compact"; + const separator = theme.fg("mdLink", "--"); - expect(rendered).toContain(theme.fg("success", '@"docs/some ')); - expect(rendered).toContain(theme.fg("success", "very long na")); - expect(rendered).toContain(theme.fg("success", 'me.txt"')); + expect(renderMessage("/new --name bla -- hello", 60, recognized)).toContain(separator); + // /compact is recognized but takes no argument, so it must match the editor and show no separator. + const unhighlighted = [ + "/compact -- hello", + "this -- however -- is fine", + "/new a --- b", + "/new x-- y", + "a --- b", + ]; + for (const text of unhighlighted) expect(renderMessage(text, 60, recognized)).not.toContain(separator); }); test("keeps multi-line quoted @paths on separate lines", () => { @@ -166,15 +148,6 @@ describe("UserMessageComponent", () => { expect(lines.join("\n")).toContain(theme.fg("success", '@"a')); }); - test("does not highlight @ or -- without a whitespace boundary", () => { - initTheme("dark"); - const email = new UserMessageComponent("email me@example.com").render(60).join("\n"); - const dashes = new UserMessageComponent("a---b").render(60).join("\n"); - - expect(email).not.toContain(theme.fg("success", "@example.com")); - expect(dashes).not.toContain(theme.fg("mdLink", "--b")); - }); - test("normalizes tabs inside quoted @paths like Markdown does", () => { initTheme("dark"); const lines = new UserMessageComponent('open @"a\tb.txt" now').render(30);