| 1 | import { |
| 2 | ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, |
| 3 | collectComposerContextReferences, |
| 4 | } from "@t3tools/shared/composerContextReferences"; |
| 5 | import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; |
| 6 | |
| 7 | /** |
| 8 | * Terminal-style prompt recall for the composer. ArrowUp on an empty |
| 9 | * composer walks back through the active thread's sent prompts, ArrowDown |
| 10 | * walks forward and restores the unsent draft past the newest entry. |
| 11 | * |
| 12 | * History is per thread and text only. It is derived from the thread's user |
| 13 | * messages on every keypress, so there is no store to persist or sync. |
| 14 | */ |
| 15 | |
| 16 | const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n"; |
| 17 | const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b[^>]*>[\s\S]*?<\/review_comment>/g; |
| 18 | const TRAILING_LEGACY_CONTEXT = |
| 19 | /\n*<(terminal_context|element_context|preview_annotation)>\n([\s\S]*?)\n<\/\1>\s*$/; |
| 20 | |
| 21 | export interface ComposerPromptHistoryMessage { |
| 22 | readonly id: string; |
| 23 | readonly role: string; |
| 24 | readonly text: string; |
| 25 | } |
| 26 | |
| 27 | export interface ComposerPromptHistoryEntry { |
| 28 | readonly id: string; |
| 29 | readonly prompt: string; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Active recall. `entryId` is resolved against the current entries on every |
| 34 | * step, so a server ack replacing an optimistic message or an older page |
| 35 | * loading cannot move the position. `recalled` is the text put in the |
| 36 | * composer; once the composer no longer matches it, the user has edited or |
| 37 | * sent and browsing is over. |
| 38 | */ |
| 39 | export interface ComposerPromptHistoryPosition { |
| 40 | readonly entryId: string; |
| 41 | readonly recalled: string; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Prefer the id. A consecutive duplicate collapse can retire the recalled |
| 46 | * id while the same text lives on under a newer one, so fall back to the |
| 47 | * newest entry with matching text. |
| 48 | */ |
| 49 | function findActive( |
| 50 | entries: ReadonlyArray<ComposerPromptHistoryEntry>, |
| 51 | position: ComposerPromptHistoryPosition, |
| 52 | ): number { |
| 53 | const byId = entries.findIndex((entry) => entry.id === position.entryId); |
| 54 | if (byId >= 0) return byId; |
| 55 | return entries.findLastIndex((entry) => entry.prompt === position.recalled); |
| 56 | } |
| 57 | |
| 58 | export interface ComposerPromptHistoryStep { |
| 59 | readonly position: ComposerPromptHistoryPosition | null; |
| 60 | readonly prompt: string; |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Drop only the review comments appended at send time, which sit at the |
| 65 | * end. Cuts the original string at the start of the trailing run of blocks |
| 66 | * so any review comment block the user typed earlier stays byte-for-byte. |
| 67 | */ |
| 68 | function stripTrailingReviewComments(prompt: string): string { |
| 69 | let cut = prompt.length; |
| 70 | for (const match of [...prompt.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)].toReversed()) { |
| 71 | const blockEnd = match.index + match[0].length; |
| 72 | if (prompt.slice(blockEnd, cut).trim().length > 0) break; |
| 73 | cut = match.index; |
| 74 | } |
| 75 | return cut === prompt.length ? prompt : prompt.slice(0, cut).trimEnd(); |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Inline terminal chips are sent as `@terminal-1:12-13` labels in the text |
| 80 | * with their content in the trailing block. Once the block is stripped the |
| 81 | * label points at nothing, so remove it too. Each block entry removes one |
| 82 | * label (the first match) and the single space beside it. Nothing else in |
| 83 | * the prompt is touched, so indented code and typed labels survive. Block |
| 84 | * headers look like `Terminal 1 lines 12-13`. |
| 85 | */ |
| 86 | function escapeRegExp(value: string): string { |
| 87 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 88 | } |
| 89 | |
| 90 | function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray<string>): string { |
| 91 | let result = prompt; |
| 92 | for (const header of headers) { |
| 93 | const match = /^(.+?) lines? (\d+(?:-\d+)?)$/.exec(header); |
| 94 | if (!match) continue; |
| 95 | const label = `@${match[1]!.trim().toLowerCase().replace(/\s+/g, "-")}:${match[2]}`; |
| 96 | // Whole label only: `@terminal-1:4` must not match inside `@terminal-1:40` |
| 97 | // or `@terminal-1:4-12`. |
| 98 | const labelPattern = new RegExp(`(?<![\\w@.-])${escapeRegExp(label)}(?![\\d-])`); |
| 99 | const index = result.search(labelPattern); |
| 100 | if (index < 0) continue; |
| 101 | let end = index + label.length; |
| 102 | let start = index; |
| 103 | if (result[end] === " ") end += 1; |
| 104 | else if (result[start - 1] === " ") start -= 1; |
| 105 | result = result.slice(0, start) + result.slice(end); |
| 106 | } |
| 107 | return result; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Reduce a sent message to the text the user typed. Send-time appends |
| 112 | * (terminal and element context blocks, preview annotations, review |
| 113 | * comments, the Claude ultrathink prefix) are stripped so a recalled prompt |
| 114 | * never carries stale context from another turn. |
| 115 | */ |
| 116 | export function recallableComposerPrompt(messageText: string): string { |
| 117 | let prompt = messageText.trim(); |
| 118 | if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) { |
| 119 | prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length); |
| 120 | } |
| 121 | |
| 122 | while (prompt.length > 0) { |
| 123 | const withoutReviewComments = stripTrailingReviewComments(prompt); |
| 124 | if (withoutReviewComments !== prompt) { |
| 125 | prompt = withoutReviewComments; |
| 126 | continue; |
| 127 | } |
| 128 | const legacy = TRAILING_LEGACY_CONTEXT.exec(prompt); |
| 129 | if (legacy) { |
| 130 | if ( |
| 131 | legacy[1] === "preview_annotation" && |
| 132 | /^<preview_annotation>\s*$/m.test(legacy[2]!) && |
| 133 | !/^(?:Id|Page|Comment|Targets): /m.test(legacy[2]!) |
| 134 | ) |
| 135 | break; |
| 136 | const headers = Array.from(legacy[2]!.matchAll(/^- (.+):$/gm), (match) => match[1]!); |
| 137 | if (legacy[1] !== "preview_annotation" && headers.length === 0) break; |
| 138 | prompt = prompt.slice(0, legacy.index).trimEnd(); |
| 139 | if (legacy[1] === "terminal_context") prompt = stripInlineTerminalLabels(prompt, headers); |
| 140 | continue; |
| 141 | } |
| 142 | break; |
| 143 | } |
| 144 | |
| 145 | // Recall is text-only: never create dangling chips without their backing records. |
| 146 | for (const reference of collectComposerContextReferences(prompt).toReversed()) { |
| 147 | let { start, end } = reference; |
| 148 | if (prompt[end] === " ") end += 1; |
| 149 | else if (prompt[start - 1] === " ") start -= 1; |
| 150 | prompt = prompt.slice(0, start) + prompt.slice(end); |
| 151 | } |
| 152 | |
| 153 | // App-composed sends are not text the user typed, so they are not history. |
| 154 | const trimmed = prompt.trim(); |
| 155 | if ( |
| 156 | trimmed === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT || |
| 157 | trimmed.startsWith(PLAN_IMPLEMENTATION_PROMPT_PREFIX) |
| 158 | ) { |
| 159 | return ""; |
| 160 | } |
| 161 | return trimmed; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Oldest first. Consecutive identical prompts collapse into the newest one, |
| 166 | * matching shell `HISTCONTROL=ignoredups`. Image-only sends have no text and |
| 167 | * are skipped. |
| 168 | */ |
| 169 | export function buildComposerPromptHistoryEntries( |
| 170 | messages: ReadonlyArray<ComposerPromptHistoryMessage>, |
| 171 | ): ComposerPromptHistoryEntry[] { |
| 172 | const entries: ComposerPromptHistoryEntry[] = []; |
| 173 | for (const message of messages) { |
| 174 | if (message.role !== "user") continue; |
| 175 | const prompt = recallableComposerPrompt(message.text); |
| 176 | if (prompt.length === 0) continue; |
| 177 | const previous = entries[entries.length - 1]; |
| 178 | if (previous && previous.prompt === prompt) { |
| 179 | entries[entries.length - 1] = { id: message.id, prompt }; |
| 180 | continue; |
| 181 | } |
| 182 | entries.push({ id: message.id, prompt }); |
| 183 | } |
| 184 | return entries; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Returns null when the key should fall through to normal caret movement. |
| 189 | * Backward starts only from an empty composer and stops at the oldest entry. |
| 190 | * Forward past the newest entry empties the composer and ends browsing. An |
| 191 | * edited or sent recall no longer matches `recalled`, so browsing restarts |
| 192 | * from scratch on the next backward step. |
| 193 | */ |
| 194 | export function stepComposerPromptHistory(input: { |
| 195 | readonly direction: "backward" | "forward"; |
| 196 | readonly entries: ReadonlyArray<ComposerPromptHistoryEntry>; |
| 197 | readonly position: ComposerPromptHistoryPosition | null; |
| 198 | readonly currentPrompt: string; |
| 199 | }): ComposerPromptHistoryStep | null { |
| 200 | const { entries, position, currentPrompt } = input; |
| 201 | const activeIndex = |
| 202 | position && position.recalled === currentPrompt ? findActive(entries, position) : -1; |
| 203 | |
| 204 | if (input.direction === "backward") { |
| 205 | if (activeIndex < 0 && currentPrompt.length > 0) return null; |
| 206 | const entry = entries[activeIndex < 0 ? entries.length - 1 : activeIndex - 1]; |
| 207 | if (!entry) return null; |
| 208 | return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; |
| 209 | } |
| 210 | |
| 211 | if (activeIndex < 0) return null; |
| 212 | const entry = entries[activeIndex + 1]; |
| 213 | if (!entry) return { position: null, prompt: "" }; |
| 214 | return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; |
| 215 | } |