export type PromptItem = { text: string; timestamp?: number; }; export function messageText(content: unknown): string { if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) return ""; return content .filter((block): block is { type: string; text: string } => { return Boolean( block && typeof block === "object" && (block as { type?: unknown }).type === "text" && typeof (block as { text?: unknown }).text === "string", ); }) .map((block) => block.text) .join("\n") .trim(); } export function printableInput(data: string): string | undefined { if (!data) return undefined; // Plain terminal input, including multi-character paste chunks. Control bytes are rejected. if (!data.includes("\x1b")) { return [...data].every((ch) => { const code = ch.codePointAt(0) ?? 0; return code >= 32 && code !== 127; }) ? data : undefined; } // Kitty keyboard protocol: CSI codepoint ; modifiers u. Modifiers are 1-indexed. const kitty = data.match(/^\x1b\[(\d+)(?:;(\d+)(?::\d+)?)?u$/); if (kitty) { const codepoint = Number.parseInt(kitty[1]!, 10); const modifier = kitty[2] ? Number.parseInt(kitty[2], 10) - 1 : 0; // Allow no modifiers or Shift only. Reject Ctrl/Alt/Super so shortcuts are never inserted. if ((modifier & ~1) !== 0 || !Number.isFinite(codepoint) || codepoint < 32) return undefined; try { return String.fromCodePoint(codepoint); } catch { return undefined; } } // xterm modifyOtherKeys: CSI 27 ; modifiers ; codepoint ~. Modifiers are 1-indexed. const modifyOtherKeys = data.match(/^\x1b\[27;(\d+);(\d+)~$/); if (modifyOtherKeys) { const modifier = Number.parseInt(modifyOtherKeys[1]!, 10) - 1; const codepoint = Number.parseInt(modifyOtherKeys[2]!, 10); if ((modifier & ~1) !== 0 || !Number.isFinite(codepoint) || codepoint < 32) return undefined; try { return String.fromCodePoint(codepoint); } catch { return undefined; } } return undefined; } export class ReverseSearchController { active = false; query = ""; originalText = ""; matches: PromptItem[] = []; selected = 0; statusMessage = ""; start(prompts: PromptItem[], originalText: string): string { this.active = true; this.query = ""; this.originalText = originalText; this.matches = this.uniquePrompts(prompts); this.selected = 0; this.statusMessage = this.matches.length === 0 ? "no prompts" : "type to search"; return this.originalText; } accept(): void { this.active = false; this.statusMessage = ""; } cancel(): string { this.active = false; this.query = ""; this.matches = []; this.selected = 0; this.statusMessage = ""; return this.originalText; } appendQuery(text: string): string { this.query += text; this.selected = 0; return this.refreshMatch(); } backspaceQuery(): string { if (this.query.length > 0) { this.query = [...this.query].slice(0, -1).join(""); this.selected = 0; } return this.refreshMatch(); } clearQuery(): string { this.query = ""; this.selected = 0; this.statusMessage = this.matches.length === 0 ? "no prompts" : "type to search"; return this.originalText; } cycleOlder(): string { return this.cycle(1); } cycleNewer(): string { return this.cycle(-1); } searchLabel(): string { const q = this.query.replace(/`/g, "\\`"); const label = this.statusMessage === "failed" ? "failing-reverse-i-search" : "reverse-i-search"; return ` ${label}\`${q}': ${this.statusMessage} `; } private cycle(delta: 1 | -1): string { if (!this.query) return this.originalText; const filtered = this.filteredMatches(); if (filtered.length === 0) return this.refreshMatch(); this.selected = (this.selected + delta + filtered.length) % filtered.length; return this.applyMatch(filtered); } private refreshMatch(): string { this.selected = 0; if (!this.query) { this.statusMessage = this.matches.length === 0 ? "no prompts" : "type to search"; return this.originalText; } return this.applyMatch(this.filteredMatches()); } private applyMatch(filtered: PromptItem[]): string { if (filtered.length === 0) { this.statusMessage = this.matches.length === 0 ? "no prompts" : "failed"; return this.originalText; } const match = filtered[this.selected] ?? filtered[0]!; this.statusMessage = `${this.selected + 1}/${filtered.length}`; return match.text; } private filteredMatches(): PromptItem[] { const q = this.query.toLowerCase(); if (!q) return []; return this.matches.filter((item) => item.text.toLowerCase().includes(q)); } private uniquePrompts(prompts: PromptItem[]): PromptItem[] { const seen = new Set(); const out: PromptItem[] = []; for (const prompt of prompts) { const text = prompt.text.trim(); if (!text || seen.has(text)) continue; seen.add(text); out.push({ ...prompt, text }); } return out; } }