Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,29 @@ 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 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 +143,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,198 @@
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 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). */
const CURSOR_ESCAPE_PATTERN = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g;

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_PATTERN = /[\uE000-\uF8FF]\uFF9E*/gu;
/** Literal mask-range characters would alias generated placeholders; messages containing them skip masking. */
const MASK_LITERAL_PATTERN = /[\uE000-\uF8FF\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));
}

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

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

/** 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 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;
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, includeBareSeparator));

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))) {
const width = visibleWidth(segment);
if (width === 0) {
// Zero-width graphemes stay literal: invisible either way, and extracted text stays 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 });
}
cursor = token.end;
}
this.text = text + source.slice(cursor);
}

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 {
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.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;
} 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 tokens in laid-out editor lines from spans on the logical source lines; reset() before each render pass. */
export class ArgTokenHighlighter {
private spans: ArgTokenSpan[][] = [];

reset(lines: readonly string[], includeBareSeparator = false): void {
this.spans = lines.map((line) => findArgTokens(line, 0, includeBareSeparator));
}

/** 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[] = [];
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;

// 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)) {
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 mid-span; wrap each segment so the token color survives 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 { builtinSlashCommandTakesArgument, 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,10 +12,16 @@ 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, 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))}`;
// 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)}`;
}

/** Renders a durable session command with the same layout as a user message. */
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,51 +1,39 @@
import { Box, type Component, Container, Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-tui";
import { parseSlashCommand } from "../../../core/slash-commands.js";
import {
Box,
type Component,
Container,
Markdown,
type MarkdownTheme,
type TableCellSelectionRegion,
} from "@earendil-works/pi-tui";
import { builtinSlashCommandTakesArgument, 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, 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),
});
}

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}`;
});
return this.markdown.render(width).map((line) => this.mask.restoreLine(line));
}

getSelectionRegions(): ReadonlyArray<TableCellSelectionRegion> {
return this.markdown.getSelectionRegions().map((region) => ({
...region,
content: this.mask.restoreText(region.content),
}));
}
Comment thread
snimu marked this conversation as resolved.

invalidate(): void {
Expand All @@ -62,14 +50,12 @@ 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;
const includeBareSeparator =
command !== undefined && commandEnd > 0 && builtinSlashCommandTakesArgument(command.name);
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, includeBareSeparator));
this.addChild(this.contentBox);
}

Expand Down
Loading
Loading