262 lines · 7.2 KB · TypeScript
RawDownload
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; 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"];const isInlineTokenSegment = (  segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },): boolean => segment.type !== "text"; 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" ||    char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER  );} 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;    }    if (segment.type === "terminal-context") {      if (remaining <= 1) {        return expandedCursor + remaining;      }      remaining -= 1;      expandedCursor += 1;      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" } | { type: "terminal-context" },): number {  if (segment.type === "text") {    return segment.text.length;  }  return 1;} function clampCollapsedComposerCursorForSegments(  segments: ReadonlyArray<    { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }  >,  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 clampCollapsedComposerCursor(text: string, cursorInput: number): number {  return clampCollapsedComposerCursorForSegments(    splitPromptIntoComposerSegments(text),    cursorInput,  );} export function collapseExpandedComposerCursor(text: string, cursorInput: number): number {  const expandedCursor = clampCursor(text, cursorInput);  const segments = splitPromptIntoComposerSegments(text);  if (segments.length === 0) {    return expandedCursor;  }   let remaining = expandedCursor;  let collapsedCursor = 0;   for (const segment of segments) {    if (segment.type === "mention") {      const expandedLength = segment.path.length + 1;      if (remaining === 0) {        return collapsedCursor;      }      if (remaining <= expandedLength) {        return collapsedCursor + 1;      }      remaining -= expandedLength;      collapsedCursor += 1;      continue;    }    if (segment.type === "terminal-context") {      if (remaining <= 1) {        return collapsedCursor + remaining;      }      remaining -= 1;      collapsedCursor += 1;      continue;    }     const segmentLength = segment.text.length;    if (remaining <= segmentLength) {      return collapsedCursor + remaining;    }    remaining -= segmentLength;    collapsedCursor += segmentLength;  }   return collapsedCursor;} export function isCollapsedCursorAdjacentToInlineToken(  text: string,  cursorInput: number,  direction: "left" | "right",): boolean {  const segments = splitPromptIntoComposerSegments(text);  if (!segments.some(isInlineTokenSegment)) {    return false;  }   const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput);  let collapsedOffset = 0;   for (const segment of segments) {    if (isInlineTokenSegment(segment)) {      if (direction === "left" && cursor === collapsedOffset + 1) {        return true;      }      if (direction === "right" && cursor === collapsedOffset) {        return true;      }    }    collapsedOffset += collapsedSegmentLength(segment);  }   return false;} export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; 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;  }  const command = match[1]?.toLowerCase();  if (command === "plan") return "plan";  return "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 };}