1import {
2 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
3 collectComposerContextReferences,
4} from "@t3tools/shared/composerContextReferences";
5import { 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
16const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n";
17const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b[^>]*>[\s\S]*?<\/review_comment>/g;
18const TRAILING_LEGACY_CONTEXT =
19 /\n*<(terminal_context|element_context|preview_annotation)>\n([\s\S]*?)\n<\/\1>\s*$/;
20
21export interface ComposerPromptHistoryMessage {
22 readonly id: string;
23 readonly role: string;
24 readonly text: string;
25}
26
27export 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 */
39export 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 */
49function 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
58export 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 */
68function 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 */
86function escapeRegExp(value: string): string {
87 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88}
89
90function 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 */
116export 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 */
169export 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 */
194export 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}