type SubtitleType = 'voice' | 'selection' | 'agent' | 'post' | 'info'; interface SubtitleShowOptions { text: string; type: SubtitleType; onAccept?: () => void; onReject?: () => void; onCancel?: () => void; onCopy?: () => void; hints?: string; autoHide?: number; /** * Maximum characters shown per page before paging kicks in. When `text` * exceeds this length the subtitle is split on sentence boundaries * (`.` / `。` / `!` / `?` / newlines) into pages and the user advances * with Space. The accept / reject callbacks fire on the LAST page — * earlier pages just advance. Set to `0` to disable paging entirely * (render the whole text in one shot). Default `220` — about three * lines on the standard subtitle bar. */ maxCharsPerPage?: number; /** * When `true`, the subtitle bar refuses every dismissal except the * explicit accept / reject gestures (Space / double-tap Space) wired * via `onAccept` / `onReject`. The × close button is hidden, Esc is * ignored, click-outside is ignored, and any-key dismiss is disabled. * * Use for cases where the host MUST collect a user signal before the * bar can go — the end-of-loop feedback closure is the canonical * example. Don't use for normal action subtitles; users expect Esc * to work as an escape valve everywhere else. * * Default `false`. */ persistent?: boolean; } /** * Options for the multi-choice subtitle (`Subtitle.showChoice(...)`). * * Renders the question + an ordered list of options. Each option can be * picked by clicking, by pressing the digit `1..N`, or — when voice is * active — by speaking the option text. Esc cancels. * * `allowFreeText` (default `true`) appends a trailing free-text input * after the options so the user can answer outside the predefined * choices. On submit the typed value is delivered to `onChoose` with * `index === -1` so the caller can distinguish free-text answers from * canonical picks. */ interface SubtitleChoiceOptions { /** The prompt itself — naturally spoken sentence. */ question: string; /** The selectable choices. 2–4 canonical options + optional "Other" slot. */ options: string[]; /** Default `true`. When false, no free-text input is shown. */ allowFreeText?: boolean; /** Placeholder shown in the free-text input. Default localised. */ freeTextLabel?: string; /** * Fires when the user makes a pick. `index` is the position in * `options` (0-based) for canonical picks, OR `-1` for a free-text * answer (in which case `value` is the typed string). */ onChoose: (value: string, index: number) => void; /** Esc / backdrop dismiss. */ onCancel?: () => void; /** Auto-hide timeout in ms. Default: stays until interacted with. */ autoHide?: number; } /** * Subtitle bar — DOM-rendered, CSS-variable themed. Shell file: public * class surface + state. Implementation in sibling modules (streaming / * choice / indicator / paging / markdown / styles / dismiss / touch). * See ../../../docs/07-subtitle-ui.md for the full design. */ declare class Subtitle { /** @internal */ _el: HTMLDivElement | null; /** @internal */ _indicator: HTMLDivElement | null; /** @internal */ _autoHideTimer: ReturnType | null; /** @internal */ _currentOpts: SubtitleShowOptions | null; /** @internal */ _delegatedHandler: ((e: Event) => void) | null; /** @internal Document keydown teardown for multi-choice mode. */ _choiceTeardown: (() => void) | null; /** @internal Click-anywhere / any-key dismiss teardown. */ _dismissTeardown: (() => void) | null; /** @internal */ _locale: string; /** @internal Indicator request queued while a subtitle is visible; * materialises on hide(). Subtitle + indicator are mutually exclusive. */ _pendingIndicator: { state: 'listening' | 'processing' | 'done'; label?: string; } | null; /** @internal Multi-page state for a single `show()` call. */ _pages: string[] | null; /** @internal */ _pageIdx: number; /** @internal Host hook for "user explicitly closed the bar". */ _closeHandler: (() => void) | null; /** @internal Live Text node the streaming bar appends into. */ _streamingTextNode: Text | null; /** @internal */ _streamingCursor: HTMLSpanElement | null; /** @internal Untranscribed tail held for sentence-boundary TTS flush. */ _streamingTtsBuffer: string; /** @internal Raw accumulated streaming text (for replaceStreamed). */ _streamingFullText: string; /** @internal Pause-hint callbacks — invokeAccept = advance, invokeReject = stop. */ _streamingPauseAccept: (() => void) | null; /** @internal */ _streamingPauseReject: (() => void) | null; /** @internal Override for the "running" indicator label after accept/reject. */ _runningLabel: string | null; /** @internal Orchestrator hook — flips GestureManager.hasSuggestion so * space-tap on a proactive prompt isn't a no-op. */ _onVisibilityChange?: (visible: boolean) => void; /** @internal Fire-and-forget TTS hook — failures swallowed (no audio * is strictly better than blocking the visual subtitle). */ _ttsProvider?: (text: string, opts: { locale: string; type: SubtitleType; }) => void; /** @internal Duck-face avatar URL — when set, `_wrapBarShell` prepends * an at the left edge of the bar so the subtitle reads as * coming from the mascot. Off by default (host opt-in). */ _avatarUrl: string | null; constructor(opts?: { locale?: string; }); setLocale(locale: string): void; setVisibilityListener(fn: (visible: boolean) => void): void; setTTSProvider(fn: typeof this._ttsProvider | null): void; /** Set the mascot-avatar URL that renders at the left edge of the * subtitle bar. Passes `null` to hide. Hosts that don't want the * avatar can skip this call entirely — default is off. */ setAvatarUrl(url: string | null): void; /** Hook for × button + outside dismiss — orchestrator typically * stops the agent loop here. */ setCloseHandler(fn: (() => void) | null): void; show(opts: SubtitleShowOptions): void; update(text: string): void; appendStreamed(delta: string): void; replaceStreamed(text: string): void; finalizeStreamed(opts?: { autoHide?: number; }): void; clearStreamed(): void; isStreaming(): boolean; applyStreamingPauseHint(opts: { hint: string; rejectHint?: string; onAccept: () => void; onReject: () => void; }): void; clearStreamingPauseHint(): void; showChoice(opts: SubtitleChoiceOptions): void; hide(): void; isVisible(): boolean; /** Gesture entry points — single space → invokeAccept, double tap → * invokeReject, Esc → invokeCancel. Without these the bar would * only respond to clicks. */ invokeAccept(): boolean; invokeReject(): boolean; invokeCancel(): boolean; /** Override the "running" indicator label; `null` falls back to the * locale-aware default ("處理中…" / "Working…"). */ setRunningLabel(label: string | null): void; showIndicator(state: 'listening' | 'processing' | 'done', label?: string): void; hideIndicator(): void; /** @internal Wrap inner HTML in the shell — pinned × button + a * `bar-scroll` container that can max-height-cap + scroll without * losing the close button. Persistent opts skip the ×. */ _wrapBarShell(innerHtml: string): string; /** @internal */ _closeLabel(): string; /** @internal Idempotent — safe to call after any wrapBarShell render. */ _wireClose(): void; /** @internal */ _handleCloseClick(): void; /** @internal Touch devices use the bar's tap-gesture instead of the * ✓/✕ buttons (redundant + heavy on small screens). Copy stays — * no gesture equivalent. Monochrome glyphs only — no emoji. */ _renderButtons(opts: SubtitleShowOptions): string; /** @internal Single delegated click listener (vs per-button) so no * dangling listeners accumulate when innerHTML is replaced mid-life. */ _wireButtons(opts: SubtitleShowOptions): void; } interface ProcessingLineHandle { /** Re-anchor (call on scroll / resize). */ update(rect: { left: number; top: number; bottom: number; width?: number; }, label?: string): void; /** Remove the indicator. Idempotent. */ dispose(): void; } interface ProcessingLineOpts { /** "Processing", "AI 正在編輯⋯" etc. Default: 'Processing'. */ label?: string; /** Element to mount into. Default: `document.body`. */ host?: HTMLElement; /** Extra px below the anchor's bottom edge. Default 6. */ gap?: number; } /** * Anchor a thin Processing indicator just below the rect — typically the * bounding box of the line / selection an AI action is operating on. * * The host is responsible for calling `.update()` on scroll / resize and * `.dispose()` once the AI returns. The indicator is non-interactive * (pointer-events: none) so it never steals clicks. */ declare function mountProcessingLine(rect: { left: number; top: number; bottom: number; width?: number; }, opts?: ProcessingLineOpts): ProcessingLineHandle; interface InlineChatTurn { /** User prompt for this turn ('improve writing' for the initial, follow-ups thereafter). */ prompt: string; /** AI text after this turn. */ result: string; } interface InlineChatSendArgs { /** The original text the user selected (never changes across the session). */ original: string; /** Every turn so far, oldest first. */ history: InlineChatTurn[]; /** The latest user prompt. */ prompt: string; } type InlineChatTransport = (args: InlineChatSendArgs) => Promise; declare class InlineChatSession { private readonly transport; private readonly original; private readonly history; constructor(original: string, transport: InlineChatTransport); /** Current best result (the last turn's text, or the original if nothing yet). */ current(): string; /** Send a follow-up prompt; on success records the turn and returns the new * text. Returns null on transport failure. */ send(prompt: string): Promise; /** Recorded turns (immutable copy). */ turns(): InlineChatTurn[]; } type InlineDiffOutcome = { kind: 'accept'; text: string; } | { kind: 'reject'; } | { kind: 'insert-after'; text: string; } | { kind: 'copy'; text: string; } | { kind: 'follow-up'; prompt: string; }; interface InlineDiffLabels { /** Accept the AI edit and commit it. Default 'Accept'. */ accept?: string; /** Reject the AI edit and restore the original. Default 'Reject'. */ reject?: string; /** Insert AI result as new line AFTER the selection. Default 'Insert below'. */ insertAfter?: string; /** Copy AI result. Default 'Copy'. */ copy?: string; /** Prompt placeholder for follow-up. Default 'Make it shorter / formal / …'. */ followUpPlaceholder?: string; /** Send follow-up button. Default 'Send'. */ send?: string; /** History collapse toggle when hidden — `{n}` substitutes the hidden count. * Default 'Show {n} earlier edits'. */ expandHistory?: string; /** History collapse toggle when expanded — `{n}` substitutes the hidden count. * Default 'Hide {n} earlier edits'. */ collapseHistory?: string; } interface InlineDiffOpts { /** Anchor rect — usually the selection bounding box. */ rect: { left: number; top: number; bottom: number; width?: number; }; /** Element to mount into. Default `document.body`. */ host?: HTMLElement; /** Extra px below the anchor's bottom edge. Default 6. */ gap?: number; /** Show the "Insert below" button. Default false. */ enableInsertAfter?: boolean; /** Show the follow-up composer. Default true. */ enableFollowUp?: boolean; /** UI strings — defaults are English. */ labels?: InlineDiffLabels; /** Called for each user choice that does NOT close the panel * (`'follow-up'` while session is in chat mode). The panel waits for the * caller to settle the new text via `.applyNewText(newText)`. */ onFollowUp?: (prompt: string) => Promise; /** Fires the instant the user clicks Reject (or hits Escape) BEFORE the * result Promise settles. Use this to abort an in-flight stream so the * network call doesn't keep running after the user has decided to discard * the edit. Also fires on `.dispose()`. */ onCancel?: () => void; } interface InlineDiffHandle { /** Re-anchor on scroll / resize. */ update(rect: { left: number; top: number; bottom: number; }): void; /** Replace the "new" text — used after a follow-up returns. */ applyNewText(newText: string): void; /** Append a streamed chunk to the current "new" text. Used by the caller * during SSE — tokens land character by character, then `streamDone()` * flips the panel out of busy state. Safe to mix with `applyNewText` for * follow-up rounds (the next stream starts fresh from the new baseline). */ applyStreamChunk(chunk: string): void; /** Mark the in-flight stream as finished. Re-enables follow-up + action * buttons. Idempotent. */ streamDone(): void; /** Begin a fresh stream — clears the current "new" text and disables * buttons. Used at the start of EVERY follow-up so the user sees the * panel reset before the next stream lands. */ streamStart(): void; /** Append a turn to the history chip stack above the diff. Used after a * successful follow-up to remind the user what they have asked so far. */ pushHistoryTurn(turn: InlineChatTurn): void; /** Programmatically reject + dispose. */ dispose(): void; /** Promise that resolves with the user's final decision (accept / reject / * insert-after / copy). Follow-up rounds keep the promise pending. */ result: Promise>; } /** * Show a strikethrough-old → new-text diff panel anchored under `rect`. * The promise resolves when the user picks a terminal action. Follow-up * prompts (chat continuation) are surfaced via `opts.onFollowUp`. */ declare function mountInlineDiff(oldText: string, newText: string, opts: InlineDiffOpts): InlineDiffHandle; export { type InlineChatSendArgs, InlineChatSession, type InlineChatTransport, type InlineChatTurn, type InlineDiffHandle, type InlineDiffLabels, type InlineDiffOpts, type InlineDiffOutcome, type ProcessingLineHandle, type ProcessingLineOpts, Subtitle, mountInlineDiff, mountProcessingLine };