/** * public/quick-reply.ts — N-quickreply: quick-reply chips + saved-prompt palette. * * Feature: A3 (Walk-away Workbench — "Quick-Reply Chips + Saved Prompt Palette") * * Provides: * - BUILT_IN_CHIPS — pre-set chips for Claude Code's most common responses. * - Immutable CRUD — addChip / removeChip / reorderChip / updateChip. * - Palette I/O — loadPalette / savePalette (localStorage, never throw). * - mountQuickReply — DOM mount: chips + inline editor. * * Security: SEC-L3 — all chip labels are set via textContent, never innerHTML, * so a snippet label such as `` appears as inert text. */ /** A single sendable snippet. */ export interface Chip { /** Stable identifier (built-in chips use a double-underscore prefix). */ id: string /** Raw bytes to send (without the Enter suffix). */ text: string /** Display label on the chip button. Set via textContent — never innerHTML. */ label: string /** If true, \r is appended to `text` when sending. */ appendEnter: boolean } // ── constants ───────────────────────────────────────────────────────────────── /** localStorage key used to persist the user palette. */ export const PALETTE_KEY = 'web-terminal:quick-reply-palette' /** The six built-in chips for Claude Code interaction. Immutable. */ export const BUILT_IN_CHIPS: readonly Chip[] = Object.freeze([ { id: '__yes', text: 'yes', label: 'yes', appendEnter: true }, { id: '__continue', text: 'continue', label: 'continue', appendEnter: true }, { id: '__1', text: '1', label: '1', appendEnter: true }, { id: '__2', text: '2', label: '2', appendEnter: true }, { id: '__3', text: '3', label: '3', appendEnter: true }, { id: '__esc', text: '\x1b', label: 'Esc', appendEnter: false }, ]) // ── immutable CRUD ──────────────────────────────────────────────────────────── /** Return a new array with `chip` appended. Does not mutate `chips`. */ export function addChip(chips: readonly Chip[], chip: Chip): Chip[] { return [...chips, chip] } /** Return a new array with the chip matching `id` removed. Does not mutate. */ export function removeChip(chips: readonly Chip[], id: string): Chip[] { return chips.filter((c) => c.id !== id) } /** * Return a new array with the chip at `fromIndex` moved to `toIndex`. * Returns a shallow copy unchanged if either index is out of bounds. * Does not mutate `chips`. */ export function reorderChip(chips: readonly Chip[], fromIndex: number, toIndex: number): Chip[] { if (fromIndex < 0 || fromIndex >= chips.length) return [...chips] if (toIndex < 0 || toIndex >= chips.length) return [...chips] const result = [...chips] const [moved] = result.splice(fromIndex, 1) // moved is defined because fromIndex is valid (checked above) result.splice(toIndex, 0, moved as Chip) return result } /** * Return a new array where the chip with `id` is shallow-merged with `patch`. * The id field cannot be changed. Does not mutate `chips` or the chip objects. */ export function updateChip( chips: readonly Chip[], id: string, patch: Partial>, ): Chip[] { return chips.map((c) => (c.id === id ? { ...c, ...patch } : c)) } // ── localStorage persistence ────────────────────────────────────────────────── function isValidChip(item: unknown): item is Chip { if (item === null || typeof item !== 'object') return false const o = item as Record return ( typeof o['id'] === 'string' && typeof o['text'] === 'string' && typeof o['label'] === 'string' && typeof o['appendEnter'] === 'boolean' ) } /** * Load the user palette from localStorage. * Returns [] on missing key, bad JSON, or unexpected shape. Never throws. */ export function loadPalette(): Chip[] { try { const raw = localStorage.getItem(PALETTE_KEY) if (!raw) return [] const parsed: unknown = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.filter(isValidChip) } catch { return [] } } /** * Persist the user palette to localStorage. * Silently ignores storage errors (quota exceeded, private mode). Never throws. */ export function savePalette(chips: readonly Chip[]): void { try { localStorage.setItem(PALETTE_KEY, JSON.stringify(chips)) } catch { // Quota exceeded or storage unavailable — best-effort, ignore. } } // ── DOM helpers ─────────────────────────────────────────────────────────────── /** Create a typed element with an optional CSS class and text content. */ function el( tag: K, cls?: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag) if (cls) node.className = cls if (text !== undefined) node.textContent = text // SEC-L3: textContent only return node } /** Remove all children of `parent`. */ function clearChildren(parent: HTMLElement): void { while (parent.firstChild) { parent.removeChild(parent.firstChild) } } // ── mountQuickReply ─────────────────────────────────────────────────────────── /** Options for mountQuickReply. */ export interface QuickReplyOpts { /** Called with the full byte string to inject into the terminal. */ onSend: (data: string) => void } /** Returned by mountQuickReply to allow cleanup. */ export interface QuickReplyHandle { /** Remove all DOM content and event listeners created by mountQuickReply. */ dispose: () => void } /** * Mount the quick-reply chip row + palette into `container`. * * Renders built-in chips, then user palette chips, then a `+` button that * opens an inline editor to add new snippets. Clicking a chip calls * `opts.onSend(text [+ '\r'])`. * * Labels are set via textContent — never innerHTML (SEC-L3). */ export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle { const { onSend } = opts // Track disposers for all event listeners so dispose() is clean. const disposers: Array<() => void> = [] function makeChipButton(chip: Chip): HTMLButtonElement { const btn = el('button', 'qr-chip') btn.textContent = chip.label // SEC-L3: textContent, never innerHTML btn.title = chip.appendEnter ? `${chip.text}⏎` : chip.text function handler() { onSend(chip.text + (chip.appendEnter ? '\r' : '')) } btn.addEventListener('click', handler) disposers.push(() => btn.removeEventListener('click', handler)) return btn } function openEditor(): void { const editor = el('div', 'qr-editor') const textInput = document.createElement('input') textInput.type = 'text' textInput.className = 'qr-editor-text' textInput.placeholder = 'Text to send' const labelInput = document.createElement('input') labelInput.type = 'text' labelInput.className = 'qr-editor-label' labelInput.placeholder = 'Label (optional, defaults to text)' const enterWrap = el('label', 'qr-editor-enter-label') const enterCheck = document.createElement('input') enterCheck.type = 'checkbox' enterCheck.className = 'qr-editor-enter' enterCheck.checked = true enterWrap.appendChild(enterCheck) enterWrap.appendChild(document.createTextNode(' Append Enter')) const saveBtn = el('button', 'qr-editor-save', 'Save') const cancelBtn = el('button', 'qr-editor-cancel', 'Cancel') function onSaveClick() { const text = textInput.value.trim() if (!text) return // empty text — no-op, editor stays open const rawLabel = labelInput.value.trim() const label = rawLabel !== '' ? rawLabel : text const newChip: Chip = { id: `user_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`, text, label, appendEnter: enterCheck.checked, } savePalette(addChip(loadPalette(), newChip)) editor.remove() render() } function onCancelClick() { editor.remove() } saveBtn.addEventListener('click', onSaveClick) cancelBtn.addEventListener('click', onCancelClick) editor.appendChild(textInput) editor.appendChild(labelInput) editor.appendChild(enterWrap) editor.appendChild(saveBtn) editor.appendChild(cancelBtn) container.appendChild(editor) } function render(): void { clearChildren(container) const row = el('div', 'qr-row') // Built-in chips first for (const chip of BUILT_IN_CHIPS) { row.appendChild(makeChipButton(chip)) } // User palette chips for (const chip of loadPalette()) { row.appendChild(makeChipButton(chip)) } // Add-snippet button const addBtn = el('button', 'qr-add-btn', '+') addBtn.title = 'Add custom snippet' addBtn.setAttribute('aria-label', 'Add custom snippet') function onAddClick() { openEditor() } addBtn.addEventListener('click', onAddClick) disposers.push(() => addBtn.removeEventListener('click', onAddClick)) row.appendChild(addBtn) container.appendChild(row) } render() return { dispose() { for (const cleanup of disposers) cleanup() clearChildren(container) }, } }