import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { kind: ComposerTriggerKind; query: string; rangeStart: number; rangeEnd: number;} const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; function clampCursor(text: string, cursor: number): number { if (!Number.isFinite(cursor)) return text.length; return Math.max(0, Math.min(text.length, Math.floor(cursor)));} function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r";} function tokenStartForCursor(text: string, cursor: number): number { let index = cursor - 1; while (index >= 0 && !isWhitespace(text[index] ?? "")) { index -= 1; } return index + 1;} export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { const collapsedCursor = clampCursor(text, cursorInput); const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { return collapsedCursor; } let remaining = collapsedCursor; let expandedCursor = 0; for (const segment of segments) { if (segment.type === "mention") { const expandedLength = segment.path.length + 1; if (remaining <= 1) { return expandedCursor + (remaining === 0 ? 0 : expandedLength); } remaining -= 1; expandedCursor += expandedLength; continue; } const segmentLength = segment.text.length; if (remaining <= segmentLength) { return expandedCursor + remaining; } remaining -= segmentLength; expandedCursor += segmentLength; } return expandedCursor;} function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { return segment.type === "mention" ? 1 : segment.text.length;} function clampCollapsedComposerCursor( segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, cursorInput: number,): number { const collapsedLength = segments.reduce( (total, segment) => total + collapsedSegmentLength(segment), 0, ); if (!Number.isFinite(cursorInput)) { return collapsedLength; } return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));} export function isCollapsedCursorAdjacentToMention( text: string, cursorInput: number, direction: "left" | "right",): boolean { const segments = splitPromptIntoComposerSegments(text); if (!segments.some((segment) => segment.type === "mention")) { return false; } const cursor = clampCollapsedComposerCursor(segments, cursorInput); let collapsedOffset = 0; for (const segment of segments) { if (segment.type === "mention") { if (direction === "left" && cursor === collapsedOffset + 1) { return true; } if (direction === "right" && cursor === collapsedOffset) { return true; } } collapsedOffset += collapsedSegmentLength(segment); } return false;} export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); if (linePrefix.startsWith("/")) { const commandMatch = /^\/(\S*)$/.exec(linePrefix); if (commandMatch) { const commandQuery = commandMatch[1] ?? ""; if (commandQuery.toLowerCase() === "model") { return { kind: "slash-model", query: "", rangeStart: lineStart, rangeEnd: cursor, }; } if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", query: commandQuery, rangeStart: lineStart, rangeEnd: cursor, }; } return null; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); if (modelMatch) { return { kind: "slash-model", query: (modelMatch[1] ?? "").trim(), rangeStart: lineStart, rangeEnd: cursor, }; } } const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); if (!token.startsWith("@")) { return null; } return { kind: "path", query: token.slice(1), rangeStart: tokenStart, rangeEnd: cursor, };} export function parseStandaloneComposerSlashCommand(text: string): Exclude< ComposerSlashCommand, "model"> | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; } return match[1]?.toLowerCase() === "plan" ? "plan" : "default";} export function replaceTextRange( text: string, rangeStart: number, rangeEnd: number, replacement: string,): { text: string; cursor: number } { const safeStart = Math.max(0, Math.min(text.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; return { text: nextText, cursor: safeStart + replacement.length };}
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
export type ComposerSlashCommand = "model" | "plan" | "default";
export interface ComposerTrigger {
kind: ComposerTriggerKind;
query: string;
rangeStart: number;
rangeEnd: number;
}
const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
function clampCursor(text: string, cursor: number): number {
if (!Number.isFinite(cursor)) return text.length;
return Math.max(0, Math.min(text.length, Math.floor(cursor)));
}
function isWhitespace(char: string): boolean {
return char === " " || char === "\n" || char === "\t" || char === "\r";
}
function tokenStartForCursor(text: string, cursor: number): number {
let index = cursor - 1;
while (index >= 0 && !isWhitespace(text[index] ?? "")) {
index -= 1;
}
return index + 1;
}
export function expandCollapsedComposerCursor(text: string, cursorInput: number): number {
const collapsedCursor = clampCursor(text, cursorInput);
const segments = splitPromptIntoComposerSegments(text);
if (segments.length === 0) {
return collapsedCursor;
}
let remaining = collapsedCursor;
let expandedCursor = 0;
for (const segment of segments) {
if (segment.type === "mention") {
const expandedLength = segment.path.length + 1;
if (remaining <= 1) {
return expandedCursor + (remaining === 0 ? 0 : expandedLength);
}
remaining -= 1;
expandedCursor += expandedLength;
continue;
}
const segmentLength = segment.text.length;
if (remaining <= segmentLength) {
return expandedCursor + remaining;
}
remaining -= segmentLength;
expandedCursor += segmentLength;
}
return expandedCursor;
}
function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number {
return segment.type === "mention" ? 1 : segment.text.length;
}
function clampCollapsedComposerCursor(
segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>,
cursorInput: number,
): number {
const collapsedLength = segments.reduce(
(total, segment) => total + collapsedSegmentLength(segment),
0,
);
if (!Number.isFinite(cursorInput)) {
return collapsedLength;
}
return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
}
export function isCollapsedCursorAdjacentToMention(
text: string,
cursorInput: number,
direction: "left" | "right",
): boolean {
const segments = splitPromptIntoComposerSegments(text);
if (!segments.some((segment) => segment.type === "mention")) {
return false;
}
const cursor = clampCollapsedComposerCursor(segments, cursorInput);
let collapsedOffset = 0;
for (const segment of segments) {
if (segment.type === "mention") {
if (direction === "left" && cursor === collapsedOffset + 1) {
return true;
}
if (direction === "right" && cursor === collapsedOffset) {
return true;
}
}
collapsedOffset += collapsedSegmentLength(segment);
}
return false;
}
export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
const cursor = clampCursor(text, cursorInput);
const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
const linePrefix = text.slice(lineStart, cursor);
if (linePrefix.startsWith("/")) {
const commandMatch = /^\/(\S*)$/.exec(linePrefix);
if (commandMatch) {
const commandQuery = commandMatch[1] ?? "";
if (commandQuery.toLowerCase() === "model") {
return {
kind: "slash-model",
query: "",
rangeStart: lineStart,
rangeEnd: cursor,
};
}
if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) {
return {
kind: "slash-command",
query: commandQuery,
rangeStart: lineStart,
rangeEnd: cursor,
};
}
return null;
}
const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
if (modelMatch) {
return {
kind: "slash-model",
query: (modelMatch[1] ?? "").trim(),
rangeStart: lineStart,
rangeEnd: cursor,
};
}
}
const tokenStart = tokenStartForCursor(text, cursor);
const token = text.slice(tokenStart, cursor);
if (!token.startsWith("@")) {
return null;
}
return {
kind: "path",
query: token.slice(1),
rangeStart: tokenStart,
rangeEnd: cursor,
};
}
export function parseStandaloneComposerSlashCommand(text: string): Exclude<
ComposerSlashCommand,
"model"
> | null {
const match = /^\/(plan|default)\s*$/i.exec(text.trim());
if (!match) {
return null;
}
return match[1]?.toLowerCase() === "plan" ? "plan" : "default";
}
export function replaceTextRange(
text: string,
rangeStart: number,
rangeEnd: number,
replacement: string,
): { text: string; cursor: number } {
const safeStart = Math.max(0, Math.min(text.length, rangeStart));
const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
return { text: nextText, cursor: safeStart + replacement.length };
}