/** * PtyChatParser — ANSI stripper, message segmenter, role classifier, * TUI prompt detector, and completion signal emitter. * * Accepts raw PTY byte chunks from the /api/terminal/stream SSE feed * ({ type: "output", data: string } payloads) and produces a structured * ChatMessage[] that downstream chat rendering components can consume. * * Design principles: * - No xterm.js dependency — pure string processing * - Deterministic given the same input sequence * - Logs structural signals only — never raw PTY content (may contain secrets) * - Debug-level console.debug under [pty-chat-parser] prefix * * TUI detection patterns (after ANSI stripping): * - Select list: lines starting with " › N." (selected) or " N." (unselected) * Uses SF's shared UI cursor glyph "›" * - Checkbox: lines starting with " › [x]" or " › [ ]" (multi-select) * - Password/text: @clack/prompts "◆ " or "?" prefix + label ending with ":" * - Completion: main prompt (❯ / › / > / $) reappears after ≥2s of no output */ // ─── Public Types ───────────────────────────────────────────────────────────── export type MessageRole = "user" | "assistant" | "system" export interface TuiPrompt { kind: "select" | "text" | "password" /** The prompt label / question text */ label: string /** For select prompts: the list of option labels */ options: string[] /** For select prompts: optional per-option descriptions */ descriptions?: string[] /** For select prompts: the currently highlighted option index (0-based) */ selectedIndex: number } export interface CompletionSignal { /** The session or context source this signal came from */ source: string /** Unix timestamp (ms) when the signal was emitted */ timestamp: number } export interface ChatMessage { /** Stable UUID — same object mutated in place while streaming */ id: string role: MessageRole /** ANSI-stripped content */ content: string /** false while streaming, true when a boundary has been detected */ complete: boolean /** Set when a TUI prompt is detected inside this message */ prompt?: TuiPrompt /** Unix timestamp (ms) of first content */ timestamp: number /** Optional images attached by the user (chat mode only — PTY parser never sets this) */ images?: { data: string; mimeType: string }[] } // ─── Subscriber Types ───────────────────────────────────────────────────────── type MessageCallback = (message: ChatMessage) => void type CompletionCallback = (signal: CompletionSignal) => void type Unsubscribe = () => void // ─── ANSI Stripper ──────────────────────────────────────────────────────────── /** * stripAnsi — remove all ANSI/VT100 escape sequences from a string. * * Handles: * - CSI sequences: \x1b[ ... final-byte (params + optional intermediates) * - OSC sequences: \x1b] ... \x07 or \x1b\\ * - SS2/SS3: \x1bN, \x1bO + one char * - DCS/PM/APC: \x1bP/\x1b^/\x1b_ ... \x1b\\ * - Simple ESC + one char (e.g. \x1bM reverse index) * - Bare \r at line start (overwrite pattern) → normalised to \n */ export function stripAnsi(s: string): string { // OSC: \x1b] ... (\x07 or \x1b\) s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // DCS / PM / APC: \x1bP, \x1b^, \x1b_ ... \x1b\ s = s.replace(/\x1b[P^_][^\x1b]*\x1b\\/g, "") // CSI: \x1b[ ... final byte (0x40–0x7e) s = s.replace(/\x1b\[[0-9;:<=>?]*[ -/]*[@-~]/g, "") // SS2 / SS3: \x1b(N|O) + one char s = s.replace(/\x1b[NO]./g, "") // All remaining ESC + one char (e.g. \x1bM, \x1b7, \x1b8, \x1b=, etc.) s = s.replace(/\x1b./g, "") // Stray lone \x1b with no following char s = s.replace(/\x1b/g, "") // \r followed by content overwrites the current line — keep the tail only // e.g. "old content\rnew content" → "new content" s = s.replace(/[^\n]*\r([^\n])/g, "$1") // Remaining bare \r → strip s = s.replace(/\r/g, "") return s } // ─── Role / Boundary Heuristics ─────────────────────────────────────────────── /** * SF prompt markers that signal the boundary between turns. * After ANSI stripping, SF's Pi agent shows one of these at the start * of a line when waiting for user input. */ const PROMPT_MARKERS = [ /^❯\s*/, // Pi default primary prompt /^›\s*/, // Pi alternate prompt /^>(\s+|$)/, // Simple > prompt (some themes) — bare ">" or "> text" /^\$(\s+|$)/, // Shell prompt fallback — bare "$" or "$ text" ] /** * System/status lines: short, bracket-wrapped messages that SF emits * at well-known lifecycle points. */ const SYSTEM_LINE_PATTERNS = [ /^\[connecting[.\u2026]*/i, /^\[connected\]/i, /^\[disconnected\]/i, /^\[auto\s+mode/i, /^\[auto-mode/i, /^\[thinking[.\u2026]*/i, /^\[done\]/i, /^\[error/i, /^sf\s+v[\d.]+/i, // version banner /^✓\s/, // short success lines /^✗\s/, // short failure lines ] /** Returns true if the (stripped) line looks like a SF input prompt */ function isPromptLine(line: string): boolean { const trimmed = line.trim() return PROMPT_MARKERS.some((r) => r.test(trimmed)) } /** Returns true if the (stripped) line looks like a system status message */ function isSystemLine(line: string): boolean { const trimmed = line.trim() if (trimmed.length === 0) return false // Short bracket-wrapped lines if (/^\[.*\]$/.test(trimmed) && trimmed.length < 80) return true return SYSTEM_LINE_PATTERNS.some((r) => r.test(trimmed)) } // ─── TUI Prompt Detection ───────────────────────────────────────────────────── /** * SF's shared UI uses "›" as cursor glyph (GLYPH.cursor = "›") * After ANSI stripping, a selected option renders as: * " › N. Label" (with leading spaces from INDENT.option) * An unselected option renders as: * " N. Label" (4 spaces instead of cursor) * Description lines render indented (5 spaces): " Some description" * * Checkbox selected: " › [x] Label" * Checkbox unselected: " › [ ] Label" or " [ ] Label" * * A select block starts with a bar line (──────) or header line and * contains ≥2 numbered option lines within a short time window. */ /** Matches a SF selected option line: " › N. Label" */ const SELECT_OPTION_SELECTED_RE = /^\s{0,4}›\s+(\d+)\.\s+(.+)/ /** Matches a SF unselected option line: " N. Label" */ const SELECT_OPTION_UNSELECTED_RE = /^\s{3,6}(\d+)\.\s+(.+)/ /** Matches a SF checkbox option: " › [x] Label" or " › [ ] Label" */ const CHECKBOX_SELECTED_RE = /^\s{0,4}›\s+\[([x ])\]\s+(.+)/i /** Matches a SF separator bar line: all ─ characters */ const BAR_LINE_RE = /^[─━─\-─]+$/ /** * Matches @clack/prompts password prompt lines: * - "◆ Some label:" (clack uses ◆ as question marker) * - "? Some label:" (alternative clack style) * - "▲ Some label:" (another clack variant) */ const CLACK_PASSWORD_RE = /^[◆▲?]\s{1,3}(.+(?:API\s*key|password|token|secret)[^:]*):?\s*$/i /** * Matches SF text input prompts — @clack style or bare labeled prompts: * - "◆ Enter project name:" * - "? What is your name?" */ const CLACK_TEXT_RE = /^[◆▲?]\s{1,3}(.+[?:])\s*$/ /** * Matches hints line rendered by SF's shared UI: * " ↑/↓ to move | enter to select" * These appear below select lists and help confirm a select block is active. */ const HINTS_RE = /↑|↓|arrow|enter to select|space to toggle/i /** Minimum option lines needed to recognise a select block */ const MIN_SELECT_OPTIONS = 2 /** Max ms to accumulate select option lines before committing the block */ const SELECT_WINDOW_MS = 300 /** * Minimum milliseconds of silence (no PTY output) after the main prompt * re-appears before a CompletionSignal is emitted. * Conservative: false positives (premature close) are worse than negatives. */ const COMPLETION_DEBOUNCE_MS = 2000 // ─── UUID Utility ───────────────────────────────────────────────────────────── function newId(): string { if (typeof crypto !== "undefined" && crypto.randomUUID) { return crypto.randomUUID() } // Fallback for environments without crypto.randomUUID return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0 return (c === "x" ? r : (r & 0x3) | 0x8).toString(16) }) } // ─── Select Block Accumulator ───────────────────────────────────────────────── interface SelectOption { index: number // 1-based as rendered by SF label: string selected: boolean } interface SelectBlock { label: string // question/header text above the options options: SelectOption[] windowTimer: ReturnType | null firstLineAt: number } // ─── PtyChatParser ──────────────────────────────────────────────────────────── /** * PtyChatParser — stateful parser for raw PTY output. * * Usage: * const parser = new PtyChatParser() * parser.onMessage((msg) => console.log(msg)) * // Feed SSE output chunks: * es.onmessage = (e) => { * const { type, data } = JSON.parse(e.data) * if (type === 'output') parser.feed(data) * } */ export class PtyChatParser { /** Raw byte buffer — accumulates across chunks until a boundary is found */ private _buffer = "" /** Stable ordered message list */ private _messages: ChatMessage[] = [] /** Subscribers for message events */ private _subscribers = new Set() /** Subscribers for completion signals */ private _completionSubscribers = new Set() /** Source label for CompletionSignal */ private _source: string /** The message currently being built (not yet complete) */ private _activeMessage: ChatMessage | null = null // ── TUI state ──────────────────────────────────────────────────────────────── /** * Pending select block accumulator. * Lives until either: enough options arrive and the window closes, * or the window timer fires with too few options. */ private _pendingSelect: SelectBlock | null = null /** * The last "question / header" line text seen before option lines start. * Reset when a new bar line appears. */ private _lastHeaderText = "" /** * Timestamp of the last PTY input received — used for completion debounce. */ private _lastInputAt = 0 /** * Set to true when main prompt line appears; cleared if more output arrives * before COMPLETION_DEBOUNCE_MS expires. Timer fires the signal. */ private _completionTimer: ReturnType | null = null /** * Whether we have already emitted a completion signal since the last * non-trivial output — guards against double-fire. */ private _completionEmitted = false /** * True when the parser has seen a prompt boundary and is waiting for user * input. The next non-system, non-prompt, non-TUI content line after the * prompt is classified as role="user" instead of "assistant". * Reset to false once that user line arrives (or when a new assistant * message explicitly starts via a different signal). */ private _awaitingInput = false constructor(source = "default") { this._source = source } // ── Public API ────────────────────────────────────────────────────────────── /** * Feed a raw PTY chunk (may contain ANSI codes, partial lines, etc.) */ feed(chunk: string): void { this._lastInputAt = Date.now() // Any new content resets pending completion — we're still receiving output if (this._completionTimer) { clearTimeout(this._completionTimer) this._completionTimer = null } this._buffer += chunk this._process() } /** Return a shallow copy of the current message list */ getMessages(): ChatMessage[] { return [...this._messages] } /** * Returns true when the parser has detected a prompt boundary and is * waiting for user input. Chat UIs can use this to show an "awaiting * input" indicator so the session does not appear stuck. */ isAwaitingInput(): boolean { return this._awaitingInput } /** * Flush any trailing partial buffer even if it does not end with a newline. * Useful for terminal UIs that leave the final status line unterminated. */ flush(): void { if (this._buffer.length === 0) return const stripped = stripAnsi(this._buffer) this._buffer = "" for (const rawLine of stripped.split("\n")) { const line = rawLine.trimEnd() if (line.length === 0) continue this._handleLine(line) } } /** * Subscribe to message events (new message or content appended). * Returns an unsubscribe function. */ onMessage(cb: MessageCallback): Unsubscribe { this._subscribers.add(cb) return () => this._subscribers.delete(cb) } /** * Subscribe to completion signals (SF returned to idle prompt after ≥2s silence). * Returns an unsubscribe function. */ onCompletionSignal(cb: CompletionCallback): Unsubscribe { this._completionSubscribers.add(cb) return () => this._completionSubscribers.delete(cb) } /** Reset all state — useful when a new session starts */ reset(): void { this._buffer = "" this._messages = [] this._activeMessage = null this._pendingSelect = null this._lastHeaderText = "" this._lastInputAt = 0 this._completionEmitted = false this._awaitingInput = false if (this._completionTimer) { clearTimeout(this._completionTimer) this._completionTimer = null } console.debug("[pty-chat-parser] reset source=%s", this._source) } // ── Internal Processing ───────────────────────────────────────────────────── private _process(): void { // Accumulate until we have at least one complete line // Process all complete lines; leave the last partial line in the buffer const lastNewline = this._buffer.lastIndexOf("\n") if (lastNewline === -1) return // no complete line yet const toProcess = this._buffer.slice(0, lastNewline + 1) this._buffer = this._buffer.slice(lastNewline + 1) const stripped = stripAnsi(toProcess) const lines = stripped.split("\n") for (const rawLine of lines) { const line = rawLine.trimEnd() this._handleLine(line) } } private _handleLine(line: string): void { const trimmed = line.trim() // Blank lines — append to active assistant message as spacing if (trimmed.length === 0) { if (this._activeMessage?.role === "assistant") { this._appendToActive("\n") } return } // ── Separator bar (─────) — signals UI block boundary ─────────────────── if (BAR_LINE_RE.test(trimmed)) { // Commit any pending select block this._commitSelectBlock() // Reset header text — next non-bar line may be a new question this._lastHeaderText = "" // Append to active assistant content if (this._activeMessage && !this._activeMessage.complete && this._activeMessage.role === "assistant") { this._appendToActive(line + "\n") } return } // ── TUI option lines — must be checked BEFORE isPromptLine ───────────── // Reason: the SF UI cursor glyph "›" is also a PROMPT_MARKER, so a // selected-option line like " › 1. Describe it now" would be mistakenly // handled as a prompt boundary if isPromptLine ran first. // Checkbox option line: " › [x] Label" / " › [ ] Label" const checkboxMatch = CHECKBOX_SELECTED_RE.exec(line) if (checkboxMatch) { this._handleCheckboxOption(checkboxMatch[1], checkboxMatch[2]) return } // Selected option line: " › N. Label" const selectedMatch = SELECT_OPTION_SELECTED_RE.exec(line) if (selectedMatch) { this._handleSelectOption(parseInt(selectedMatch[1], 10), selectedMatch[2], true) return } // Unselected option line: " N. Label" (3–6 leading spaces, no ›) // Guard: must look like a numbered option — not a description indent line const unselectedMatch = SELECT_OPTION_UNSELECTED_RE.exec(line) if (unselectedMatch && !SELECT_OPTION_SELECTED_RE.test(line)) { this._handleSelectOption(parseInt(unselectedMatch[1], 10), unselectedMatch[2], false) return } // Hints line (↑/↓ navigation hints) — end of a select block if (HINTS_RE.test(trimmed)) { this._commitSelectBlock() if (this._activeMessage && !this._activeMessage.complete && this._activeMessage.role === "assistant") { this._appendToActive(line + "\n") } return } // ── Prompt line → boundary ─────────────────────────────────────────────── if (isPromptLine(trimmed)) { // Commit any pending select block before closing this turn this._commitSelectBlock() // Complete any active message if (this._activeMessage) { this._completeActive() console.debug( "[pty-chat-parser] boundary: prompt detected, completed msg=%s role=%s source=%s", this._activeMessage?.id ?? "(none)", this._activeMessage?.role ?? "(none)", this._source, ) } // Schedule completion signal with debounce this._scheduleCompletionSignal() // Start a new user message (the text after the prompt marker is user input) const userText = trimmed.replace(PROMPT_MARKERS[0], "") .replace(PROMPT_MARKERS[1], "") .replace(PROMPT_MARKERS[2], "") .replace(PROMPT_MARKERS[3], "") .trim() if (userText.length > 0) { const msg = this._startMessage("user", userText) this._completeMessage(msg) // user lines are typically single-line this._awaitingInput = false } else { // Bare prompt with no inline user text — mark as awaiting input // so the next content line is classified as user input. this._awaitingInput = true } return } // ── System / status line ───────────────────────────────────────────────── if (isSystemLine(trimmed)) { // Complete any active non-system message first if (this._activeMessage && this._activeMessage.role !== "system") { this._completeActive() } // System messages are always self-contained single lines const msg = this._startMessage("system", trimmed) this._completeMessage(msg) console.debug( "[pty-chat-parser] system line detected id=%s source=%s", msg.id, this._source, ) return } // ── @clack/prompts TUI prompts ─────────────────────────────────────────── // Password prompt: @clack/prompts "◆ Paste your Anthropic API key:" const passwordMatch = CLACK_PASSWORD_RE.exec(trimmed) if (passwordMatch) { this._handlePasswordPrompt(passwordMatch[1]) return } // Text prompt: @clack/prompts "◆ Enter project name:" const textMatch = CLACK_TEXT_RE.exec(trimmed) if (textMatch) { this._handleTextPrompt(textMatch[1]) return } // ── Question/header line (before options) ──────────────────────────────── // SF renders a header line or question text above select options. // Capture it so we can use it as the TuiPrompt.label when options arrive. if (this._looksLikeQuestionHeader(line)) { this._lastHeaderText = trimmed } // ── Awaiting input → classify as user ────────────────────────────────── // After a bare prompt line (e.g. "❯ \n"), the next content line is // the user's typed input echoed back by the PTY (without prompt prefix). if (this._awaitingInput) { this._awaitingInput = false const msg = this._startMessage("user", trimmed) this._completeMessage(msg) console.debug( "[pty-chat-parser] user input detected (post-prompt echo) id=%s source=%s", msg.id, this._source, ) return } // ── Regular content line → assistant ──────────────────────────────────── if ( this._activeMessage === null || this._activeMessage.complete || this._activeMessage.role !== "assistant" ) { // Start a new assistant message this._activeMessage = this._startMessage("assistant", "") console.debug( "[pty-chat-parser] role boundary: started assistant msg=%s source=%s", this._activeMessage.id, this._source, ) } this._appendToActive(line + "\n") } // ── TUI Prompt Handlers ───────────────────────────────────────────────────── private _handleSelectOption(num: number, label: string, isSelected: boolean): void { const cleanLabel = label.trim() if (!this._pendingSelect) { // Start a new accumulation block this._pendingSelect = { label: this._lastHeaderText, options: [], windowTimer: null, firstLineAt: Date.now(), } // Set window timer — if not enough options arrive, discard this._pendingSelect.windowTimer = setTimeout(() => { this._commitSelectBlock() }, SELECT_WINDOW_MS) } // Upsert option by its 1-based index const block = this._pendingSelect const existing = block.options.find((o) => o.index === num) if (existing) { existing.label = cleanLabel existing.selected = isSelected } else { block.options.push({ index: num, label: cleanLabel, selected: isSelected }) } } private _handleCheckboxOption(checked: string, label: string): void { const isSelected = checked.toLowerCase() === "x" // Reuse select option logic — checkboxes map to select with multiple selection // For simplicity, we detect checkbox as a variant of select this._handleSelectOption(this._pendingSelect?.options.length ?? 0 + 1, label, isSelected) } private _handlePasswordPrompt(label: string): void { // Ensure there's an active assistant message to attach the prompt to if (!this._activeMessage || this._activeMessage.complete || this._activeMessage.role !== "assistant") { this._activeMessage = this._startMessage("assistant", "") } const prompt: TuiPrompt = { kind: "password", label: label.trim(), options: [], selectedIndex: 0, } this._activeMessage.prompt = prompt this._notify(this._activeMessage) console.debug( "[pty-chat-parser] tui prompt detected kind=password source=%s", this._source, ) } private _handleTextPrompt(label: string): void { // Ensure there's an active assistant message to attach the prompt to if (!this._activeMessage || this._activeMessage.complete || this._activeMessage.role !== "assistant") { this._activeMessage = this._startMessage("assistant", "") } const prompt: TuiPrompt = { kind: "text", label: label.trim(), options: [], selectedIndex: 0, } this._activeMessage.prompt = prompt this._notify(this._activeMessage) console.debug( "[pty-chat-parser] tui prompt detected kind=text label=%s source=%s", label.trim(), this._source, ) } private _commitSelectBlock(): void { if (!this._pendingSelect) return const block = this._pendingSelect this._pendingSelect = null if (block.windowTimer) { clearTimeout(block.windowTimer) } if (block.options.length < MIN_SELECT_OPTIONS) { // Not enough options — treat as regular content, not a select prompt return } // Sort options by their 1-based index block.options.sort((a, b) => a.index - b.index) const selectedOpt = block.options.find((o) => o.selected) const selectedIndex = selectedOpt ? block.options.indexOf(selectedOpt) : 0 const prompt: TuiPrompt = { kind: "select", label: block.label, options: block.options.map((o) => o.label), selectedIndex, } // Ensure there's an active assistant message to attach the prompt to if (!this._activeMessage || this._activeMessage.complete || this._activeMessage.role !== "assistant") { this._activeMessage = this._startMessage("assistant", "") } this._activeMessage.prompt = prompt this._notify(this._activeMessage) console.debug( "[pty-chat-parser] tui prompt detected kind=select options=%d selectedIndex=%d source=%s", prompt.options.length, selectedIndex, this._source, ) } /** * Returns true if a stripped line looks like a question/header text that * precedes a select list. Criteria: non-empty, not a system line, not an * option line, and appeared after a bar separator. */ private _looksLikeQuestionHeader(line: string): boolean { const trimmed = line.trim() if (trimmed.length === 0) return false if (BAR_LINE_RE.test(trimmed)) return false if (isSystemLine(trimmed)) return false if (SELECT_OPTION_SELECTED_RE.test(line)) return false if (SELECT_OPTION_UNSELECTED_RE.test(line)) return false if (CHECKBOX_SELECTED_RE.test(line)) return false // Only capture as header if we just saw a bar (header text is fresh) // — otherwise this rule would capture any assistant content return this._lastHeaderText === "" || this._pendingSelect !== null } // ── Completion Signal ──────────────────────────────────────────────────────── /** * Schedule a CompletionSignal to fire after COMPLETION_DEBOUNCE_MS of silence. * Any subsequent PTY input in feed() cancels and resets the timer (see feed()). */ private _scheduleCompletionSignal(): void { if (this._completionTimer) { clearTimeout(this._completionTimer) } this._completionEmitted = false const scheduledAt = Date.now() this._completionTimer = setTimeout(() => { this._completionTimer = null if (this._completionEmitted) return const elapsed = Date.now() - scheduledAt this._completionEmitted = true const signal: CompletionSignal = { source: this._source, timestamp: Date.now(), } console.debug( "[pty-chat-parser] completion signal emitted source=%s debounce=%dms", this._source, elapsed, ) for (const cb of this._completionSubscribers) { try { cb(signal) } catch { /* subscriber error */ } } }, COMPLETION_DEBOUNCE_MS) console.debug( "[pty-chat-parser] completion signal scheduled (debounce=%dms) source=%s", COMPLETION_DEBOUNCE_MS, this._source, ) } // ── Message Lifecycle ─────────────────────────────────────────────────────── private _startMessage(role: MessageRole, content: string): ChatMessage { const msg: ChatMessage = { id: newId(), role, content, complete: false, timestamp: Date.now(), } this._messages.push(msg) this._activeMessage = msg this._notify(msg) return msg } private _appendToActive(text: string): void { if (!this._activeMessage || this._activeMessage.complete) return this._activeMessage.content += text this._notify(this._activeMessage) } private _completeActive(): void { if (!this._activeMessage || this._activeMessage.complete) return this._completeMessage(this._activeMessage) } private _completeMessage(msg: ChatMessage): void { // Trim trailing whitespace from completed messages msg.content = msg.content.trimEnd() msg.complete = true if (this._activeMessage === msg) this._activeMessage = null this._notify(msg) console.debug( "[pty-chat-parser] message complete id=%s role=%s source=%s", msg.id, msg.role, this._source, ) } private _notify(msg: ChatMessage): void { for (const cb of this._subscribers) { try { cb(msg) } catch { /* subscriber error */ } } } }