Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- 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.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
visibleWidth,
} from "@earendil-works/pi-tui";
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;
Expand All @@ -25,6 +28,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<AppKeybinding, () => void> = new Map();

// Special handlers that can be dynamically replaced
Expand Down Expand Up @@ -69,13 +73,30 @@ 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);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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) {
return displayText;
}

const match = /^(\s*)\/(\S+)/.exec(lineText);
const match = COMMAND_TOKEN_PATTERN.exec(lineText);
if (!match) {
return displayText;
}
Expand Down Expand Up @@ -123,6 +144,9 @@ export class CustomEditor extends Editor {
}

override render(width: number): string[] {
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);
if (this.placeholder && this.getText().length === 0 && lines.length >= 2) {
lines = [lines[0]!, this.renderPlaceholderLine(width), ...lines.slice(2)];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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;

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" });

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; 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;
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]) });
}
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,
includeBareSeparator = false,
): string {
let result = "";
let offset = 0;
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;
}
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) {
if (MASK_LITERAL_PATTERN.test(source)) {
this.text = source;
return;
}
Comment thread
snimu marked this conversation as resolved.
const tokens: ArgTokenSpan[] = [];
if (commandEnd > 0) {
tokens.push({ start: 0, end: commandEnd, color: "accent" });
}
tokens.push(...findArgTokens(source, commandEnd, commandEnd > 0));

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)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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);
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/**
* 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[], includeBareSeparator = false): void {
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.
*/
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;
// 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);
}
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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, undefined, true),
): 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))}`;
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,51 +1,37 @@
import { Box, type Component, Container, Markdown, type MarkdownTheme, visibleWidth } 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 { 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));
}

getSelectionRegions(): ReadonlyArray<TableCellSelectionRegion> {
return this.markdown.getSelectionRegions();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
Comment thread
snimu marked this conversation as resolved.

invalidate(): void {
Expand All @@ -62,14 +48,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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ import { formatKeyText, keyHint, keyText, rawKeyHint } from "./components/keybin
import { createMermaidMarkdownTransform } from "./components/mermaid.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 {
MalformedRefinementOutcomeMessageComponent,
RefinementOutcomeMessageComponent,
Expand Down Expand Up @@ -324,9 +325,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, true))}`;
}

function isExpandable(obj: unknown): obj is Expandable {
Expand Down
Loading
Loading