334 lines · 9.9 KB · TypeScript
RawDownload
import { type ThreadId } from "@t3tools/contracts"; import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; export interface TerminalContextSelection {  terminalId: string;  terminalLabel: string;  lineStart: number;  lineEnd: number;  text: string;} export interface TerminalContextDraft extends TerminalContextSelection {  id: string;  threadId: ThreadId;  createdAt: string;} export interface ExtractedTerminalContexts {  promptText: string;  contextCount: number;  previewTitle: string | null;  contexts: ParsedTerminalContextEntry[];} export interface DisplayedUserMessageState {  visibleText: string;  copyText: string;  contextCount: number;  previewTitle: string | null;  contexts: ParsedTerminalContextEntry[];  /**   * Element-context entries extracted from the trailing `<element_context>`   * block (if any). Stripped from `visibleText` so the raw block doesn't   * leak into the user's bubble.   */  elementContexts: ParsedElementContextEntry[];} export interface ParsedTerminalContextEntry {  header: string;  body: string;} export const INLINE_TERMINAL_CONTEXT_PLACEHOLDER = "\uFFFC"; const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN =  /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/; export function normalizeTerminalContextText(text: string): string {  return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");} export function hasTerminalContextText(context: { text: string }): boolean {  return normalizeTerminalContextText(context.text).length > 0;} export function isTerminalContextExpired(context: { text: string }): boolean {  return !hasTerminalContextText(context);} export function filterTerminalContextsWithText<T extends { text: string }>(  contexts: ReadonlyArray<T>,): T[] {  return contexts.filter((context) => hasTerminalContextText(context));} function normalizeTerminalContextSelection(  selection: TerminalContextSelection,): TerminalContextSelection | null {  const text = normalizeTerminalContextText(selection.text);  const terminalId = selection.terminalId.trim();  const terminalLabel = selection.terminalLabel.trim();  if (text.length === 0 || terminalId.length === 0 || terminalLabel.length === 0) {    return null;  }  const lineStart = Math.max(1, Math.floor(selection.lineStart));  const lineEnd = Math.max(lineStart, Math.floor(selection.lineEnd));  return {    terminalId,    terminalLabel,    lineStart,    lineEnd,    text,  };} function formatTerminalContextRange(selection: { lineStart: number; lineEnd: number }): string {  return selection.lineStart === selection.lineEnd    ? `line ${selection.lineStart}`    : `lines ${selection.lineStart}-${selection.lineEnd}`;} export function formatTerminalContextLabel(selection: {  terminalLabel: string;  lineStart: number;  lineEnd: number;}): string {  return `${selection.terminalLabel} ${formatTerminalContextRange(selection)}`;} export function formatInlineTerminalContextLabel(selection: {  terminalLabel: string;  lineStart: number;  lineEnd: number;}): string {  const terminalLabel = selection.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-");  const range =    selection.lineStart === selection.lineEnd      ? `${selection.lineStart}`      : `${selection.lineStart}-${selection.lineEnd}`;  return `@${terminalLabel}:${range}`;} function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] {  return normalizeTerminalContextText(selection.text)    .split("\n")    .map((line, index) => `  ${selection.lineStart + index} | ${line}`);} export function buildTerminalContextBlock(  contexts: ReadonlyArray<TerminalContextSelection>,): string {  const normalizedContexts: TerminalContextSelection[] = [];  for (const context of contexts) {    const normalized = normalizeTerminalContextSelection(context);    if (normalized !== null) {      normalizedContexts.push(normalized);    }  }  if (normalizedContexts.length === 0) {    return "";  }  const lines: string[] = [];  for (let index = 0; index < normalizedContexts.length; index += 1) {    const context = normalizedContexts[index]!;    lines.push(`- ${formatTerminalContextLabel(context)}:`);    lines.push(...buildTerminalContextBodyLines(context));    if (index < normalizedContexts.length - 1) {      lines.push("");    }  }  return ["<terminal_context>", ...lines, "</terminal_context>"].join("\n");} export function materializeInlineTerminalContextPrompt(  prompt: string,  contexts: ReadonlyArray<{    terminalLabel: string;    lineStart: number;    lineEnd: number;  }>,): string {  let nextContextIndex = 0;  let result = "";   for (const char of prompt) {    if (char !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {      result += char;      continue;    }    const context = contexts[nextContextIndex] ?? null;    nextContextIndex += 1;    if (!context) {      continue;    }    result += formatInlineTerminalContextLabel(context);  }   return result;} export function appendTerminalContextsToPrompt(  prompt: string,  contexts: ReadonlyArray<TerminalContextSelection>,): string {  const trimmedPrompt = materializeInlineTerminalContextPrompt(prompt, contexts).trim();  const contextBlock = buildTerminalContextBlock(contexts);  if (contextBlock.length === 0) {    return trimmedPrompt;  }  return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${contextBlock}` : contextBlock;} export function extractTrailingTerminalContexts(prompt: string): ExtractedTerminalContexts {  const match = TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN.exec(prompt);  if (!match) {    return {      promptText: prompt,      contextCount: 0,      previewTitle: null,      contexts: [],    };  }  const promptText = prompt.slice(0, match.index).replace(/\n+$/, "");  const parsedContexts = parseTerminalContextEntries(match[1] ?? "");  return {    promptText,    contextCount: parsedContexts.length,    previewTitle:      parsedContexts.length > 0        ? parsedContexts            .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header))            .join("\n\n")        : null,    contexts: parsedContexts,  };} export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState {  // Order matters: send-time appends `<terminal_context>` first, then  // `<element_context>` last. Strip element first so the (now-trailing)  // terminal block can be matched by `extractTrailingTerminalContexts`.  const extractedElement = extractTrailingElementContexts(prompt);  const extractedTerminal = extractTrailingTerminalContexts(extractedElement.promptText);  return {    visibleText: extractedTerminal.promptText,    copyText: prompt,    contextCount: extractedTerminal.contextCount,    previewTitle: extractedTerminal.previewTitle,    contexts: extractedTerminal.contexts,    elementContexts: extractedElement.contexts,  };} function parseTerminalContextEntries(block: string): ParsedTerminalContextEntry[] {  const entries: ParsedTerminalContextEntry[] = [];  let current: { header: string; bodyLines: string[] } | null = null;   const commitCurrent = () => {    if (!current) {      return;    }    entries.push({      header: current.header,      body: current.bodyLines.join("\n").trimEnd(),    });    current = null;  };   for (const rawLine of block.split("\n")) {    const headerMatch = /^- (.+):$/.exec(rawLine);    if (headerMatch) {      commitCurrent();      current = {        header: headerMatch[1]!,        bodyLines: [],      };      continue;    }    if (!current) {      continue;    }    if (rawLine.startsWith("  ")) {      current.bodyLines.push(rawLine.slice(2));      continue;    }    if (rawLine.length === 0) {      current.bodyLines.push("");    }  }   commitCurrent();  return entries;} export function countInlineTerminalContextPlaceholders(prompt: string): number {  let count = 0;  for (const char of prompt) {    if (char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {      count += 1;    }  }  return count;} export function ensureInlineTerminalContextPlaceholders(  prompt: string,  terminalContextCount: number,): string {  const missingCount = terminalContextCount - countInlineTerminalContextPlaceholders(prompt);  if (missingCount <= 0) {    return prompt;  }  return `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER.repeat(missingCount)}${prompt}`;} function isInlineTerminalContextBoundaryWhitespace(char: string | undefined): boolean {  return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r";} export function insertInlineTerminalContextPlaceholder(  prompt: string,  cursorInput: number,): { prompt: string; cursor: number; contextIndex: number } {  const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput)));  const needsLeadingSpace = !isInlineTerminalContextBoundaryWhitespace(prompt[cursor - 1]);  const replacement = `${needsLeadingSpace ? " " : ""}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} `;  const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor;  return {    prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`,    cursor: cursor + replacement.length,    contextIndex: countInlineTerminalContextPlaceholders(prompt.slice(0, cursor)),  };} export function stripInlineTerminalContextPlaceholders(prompt: string): string {  return prompt.replaceAll(INLINE_TERMINAL_CONTEXT_PLACEHOLDER, "");} export function removeInlineTerminalContextPlaceholder(  prompt: string,  contextIndex: number,): { prompt: string; cursor: number } {  if (contextIndex < 0) {    return { prompt, cursor: prompt.length };  }   let placeholderIndex = 0;  for (let index = 0; index < prompt.length; index += 1) {    if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {      continue;    }    if (placeholderIndex === contextIndex) {      return {        prompt: prompt.slice(0, index) + prompt.slice(index + 1),        cursor: index,      };    }    placeholderIndex += 1;  }   return { prompt, cursor: prompt.length };}