- Prefix unused params/vars with _ in db-writer.js, system-context.js, record-promoter.js, a2a-transport.js - Remove unused imports: createServer (a2a-agent-server.js), dirname/join/resolve (a2a-transport.js), KNOWN_PREFERENCE_KEYS (preferences.js) - Remove unused private field _lastInputAt from pty-chat-parser.ts - Prefix unused test variable currentProject in uok-metrics-exposition.test.mjs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
861 lines
28 KiB
TypeScript
861 lines
28 KiB
TypeScript
/**
|
||
* 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<typeof setTimeout> | 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<MessageCallback>();
|
||
/** Subscribers for completion signals */
|
||
private _completionSubscribers = new Set<CompletionCallback>();
|
||
/** 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 = "";
|
||
|
||
/**
|
||
* 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<typeof setTimeout> | 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 */
|
||
}
|
||
}
|
||
}
|
||
}
|