feat: add text clipboard copy/read with mouse selection support
Add cross-platform text clipboard operations (macOS, Linux, Windows) with OSC 52 support for SSH/tmux environments. Wire up onMouseUp and Ctrl+Y in the TUI to copy selected text to the system clipboard via OpenTUI's renderer selection API.
This commit is contained in:
@@ -15,6 +15,7 @@ import type {
|
||||
} from "@/types/streaming";
|
||||
import type { ToolCall, ToolResult } from "@/types/tools";
|
||||
import { createStreamingAgent } from "@services/agent-stream";
|
||||
import { createThinkingParser } from "@services/reasoning/thinking-parser";
|
||||
import { appStore } from "@tui-solid/context/app";
|
||||
|
||||
// Re-export for convenience
|
||||
@@ -26,48 +27,72 @@ export type { StreamingChatOptions } from "@interfaces/StreamingChatOptions";
|
||||
|
||||
const createTUIStreamCallbacks = (
|
||||
options?: Partial<StreamingChatOptions>,
|
||||
): StreamCallbacks => ({
|
||||
onContentChunk: (content: string) => {
|
||||
appStore.appendStreamContent(content);
|
||||
},
|
||||
): { callbacks: StreamCallbacks; resetParser: () => void } => {
|
||||
const parser = createThinkingParser();
|
||||
|
||||
onToolCallStart: (toolCall: PartialToolCall) => {
|
||||
appStore.setCurrentToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
description: `Calling ${toolCall.name}...`,
|
||||
status: "pending",
|
||||
});
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolCall: ToolCall) => {
|
||||
appStore.updateToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
status: "running",
|
||||
});
|
||||
},
|
||||
|
||||
onModelSwitch: (info: ModelSwitchInfo) => {
|
||||
const emitThinking = (thinking: string | null): void => {
|
||||
if (!thinking) return;
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `Model switched: ${info.from} → ${info.to} (${info.reason})`,
|
||||
type: "thinking",
|
||||
content: thinking,
|
||||
});
|
||||
options?.onModelSwitch?.(info);
|
||||
},
|
||||
};
|
||||
|
||||
onComplete: () => {
|
||||
appStore.completeStreaming();
|
||||
},
|
||||
const callbacks: StreamCallbacks = {
|
||||
onContentChunk: (content: string) => {
|
||||
const result = parser.feed(content);
|
||||
if (result.visible) {
|
||||
appStore.appendStreamContent(result.visible);
|
||||
}
|
||||
emitThinking(result.thinking);
|
||||
},
|
||||
|
||||
onError: (error: string) => {
|
||||
appStore.cancelStreaming();
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
onToolCallStart: (toolCall: PartialToolCall) => {
|
||||
appStore.setCurrentToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
description: `Calling ${toolCall.name}...`,
|
||||
status: "pending",
|
||||
});
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolCall: ToolCall) => {
|
||||
appStore.updateToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
status: "running",
|
||||
});
|
||||
},
|
||||
|
||||
onModelSwitch: (info: ModelSwitchInfo) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `Model switched: ${info.from} → ${info.to} (${info.reason})`,
|
||||
});
|
||||
options?.onModelSwitch?.(info);
|
||||
},
|
||||
|
||||
onComplete: () => {
|
||||
const flushed = parser.flush();
|
||||
if (flushed.visible) {
|
||||
appStore.appendStreamContent(flushed.visible);
|
||||
}
|
||||
emitThinking(flushed.thinking);
|
||||
appStore.completeStreaming();
|
||||
},
|
||||
|
||||
onError: (error: string) => {
|
||||
parser.reset();
|
||||
appStore.cancelStreaming();
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: error,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return { callbacks, resetParser: () => parser.reset() };
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Agent Options with TUI Integration
|
||||
@@ -164,9 +189,13 @@ export const runStreamingChat = async (
|
||||
appStore.startStreaming();
|
||||
|
||||
// Create callbacks that update the TUI
|
||||
const streamCallbacks = createTUIStreamCallbacks(options);
|
||||
const { callbacks: streamCallbacks, resetParser } =
|
||||
createTUIStreamCallbacks(options);
|
||||
const agentOptions = createAgentOptionsWithTUI(options);
|
||||
|
||||
// Reset parser for fresh session
|
||||
resetParser();
|
||||
|
||||
// Create and run the streaming agent
|
||||
const agent = createStreamingAgent(
|
||||
process.cwd(),
|
||||
@@ -210,7 +239,8 @@ export const createStreamingChat = (
|
||||
run: (messages: Message[]) => Promise<AgentResult>;
|
||||
stop: () => void;
|
||||
} => {
|
||||
const streamCallbacks = createTUIStreamCallbacks(options);
|
||||
const { callbacks: streamCallbacks, resetParser } =
|
||||
createTUIStreamCallbacks(options);
|
||||
const agentOptions = createAgentOptionsWithTUI(options);
|
||||
|
||||
const agent = createStreamingAgent(
|
||||
@@ -221,6 +251,7 @@ export const createStreamingChat = (
|
||||
|
||||
return {
|
||||
run: async (messages: Message[]) => {
|
||||
resetParser();
|
||||
appStore.setMode("thinking");
|
||||
appStore.startThinking();
|
||||
appStore.startStreaming();
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* - Windows: Uses PowerShell
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { readFile, unlink } from "fs/promises";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ImageMediaType, PastedImage } from "@/types/image";
|
||||
import { runCommand } from "@services/clipboard/run-command";
|
||||
|
||||
/** Supported image formats for clipboard operations */
|
||||
export const SUPPORTED_IMAGE_FORMATS: ImageMediaType[] = [
|
||||
@@ -30,32 +30,6 @@ const detectPlatform = (): "darwin" | "linux" | "win32" | "unsupported" => {
|
||||
return "unsupported";
|
||||
};
|
||||
|
||||
const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{ stdout: Buffer; stderr: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args);
|
||||
const stdout: Buffer[] = [];
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => stdout.push(data));
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout: Buffer.concat(stdout), stderr });
|
||||
} else {
|
||||
reject(new Error(`Command failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
const detectImageType = (buffer: Buffer): ImageMediaType | null => {
|
||||
// PNG: 89 50 4E 47
|
||||
if (
|
||||
|
||||
40
src/services/clipboard/read-clipboard.ts
Normal file
40
src/services/clipboard/read-clipboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Unified clipboard reader - Attempts image first, then text fallback
|
||||
*
|
||||
* Combines image reading (from existing clipboard-service patterns)
|
||||
* with text reading to provide a single readClipboard entry point.
|
||||
*/
|
||||
|
||||
import type { ClipboardContent } from "@/types/clipboard";
|
||||
import {
|
||||
CLIPBOARD_MIME_TEXT,
|
||||
CLIPBOARD_MIME_IMAGE_PNG,
|
||||
} from "@constants/clipboard";
|
||||
import { readClipboardImage } from "@services/clipboard-service";
|
||||
import { readClipboardText } from "@services/clipboard/text-clipboard";
|
||||
|
||||
/**
|
||||
* Read clipboard content, attempting image first then text fallback.
|
||||
* Returns ClipboardContent with appropriate MIME type, or null if empty.
|
||||
*/
|
||||
export const readClipboard = async (): Promise<ClipboardContent | null> => {
|
||||
const image = await readClipboardImage();
|
||||
|
||||
if (image) {
|
||||
return {
|
||||
data: image.data,
|
||||
mime: CLIPBOARD_MIME_IMAGE_PNG,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await readClipboardText();
|
||||
|
||||
if (text) {
|
||||
return {
|
||||
data: text,
|
||||
mime: CLIPBOARD_MIME_TEXT,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
99
src/services/clipboard/run-command.ts
Normal file
99
src/services/clipboard/run-command.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Shared command execution helpers for clipboard operations
|
||||
*
|
||||
* Extracted from clipboard-service.ts to be reused across
|
||||
* text and image clipboard modules.
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { CLIPBOARD_COMMAND_TIMEOUT_MS } from "@constants/clipboard";
|
||||
|
||||
/** Run a command and return stdout as Buffer and stderr as string */
|
||||
export const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{ stdout: Buffer; stderr: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args);
|
||||
const stdout: Buffer[] = [];
|
||||
let stderr = "";
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`Command timed out after ${CLIPBOARD_COMMAND_TIMEOUT_MS}ms: ${command}`));
|
||||
}, CLIPBOARD_COMMAND_TIMEOUT_MS);
|
||||
|
||||
proc.stdout.on("data", (data: Buffer) => stdout.push(data));
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout: Buffer.concat(stdout), stderr });
|
||||
} else {
|
||||
reject(new Error(`Command failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Run a command and return stdout as trimmed string */
|
||||
export const runCommandText = async (
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<string> => {
|
||||
const { stdout } = await runCommand(command, args);
|
||||
return stdout.toString().trim();
|
||||
};
|
||||
|
||||
/** Spawn a command with stdin piping, write text, and wait for exit */
|
||||
export const runCommandWithStdin = (
|
||||
command: string,
|
||||
args: string[],
|
||||
input: string,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`Command timed out after ${CLIPBOARD_COMMAND_TIMEOUT_MS}ms: ${command}`));
|
||||
}, CLIPBOARD_COMMAND_TIMEOUT_MS);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Command failed with code ${code}: ${command}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
});
|
||||
};
|
||||
|
||||
/** Check if a command is available on the system */
|
||||
export const commandExists = (name: string): boolean => {
|
||||
try {
|
||||
execSync(`which ${name}`, { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
198
src/services/clipboard/text-clipboard.ts
Normal file
198
src/services/clipboard/text-clipboard.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Text clipboard service - Cross-platform text copy/read
|
||||
*
|
||||
* Supports macOS, Linux (Wayland + X11), Windows, and
|
||||
* OSC 52 for SSH/tmux environments.
|
||||
*/
|
||||
|
||||
import type { CopyMethod } from "@/types/clipboard";
|
||||
import {
|
||||
OSC52_SEQUENCE_PREFIX,
|
||||
OSC52_SEQUENCE_SUFFIX,
|
||||
TMUX_DCS_PREFIX,
|
||||
TMUX_DCS_SUFFIX,
|
||||
TMUX_ENV_VAR,
|
||||
SCREEN_ENV_VAR,
|
||||
WAYLAND_DISPLAY_ENV_VAR,
|
||||
} from "@constants/clipboard";
|
||||
import {
|
||||
runCommandText,
|
||||
runCommandWithStdin,
|
||||
commandExists,
|
||||
} from "@services/clipboard/run-command";
|
||||
|
||||
/**
|
||||
* Write text to clipboard via OSC 52 escape sequence.
|
||||
* Works over SSH by having the terminal emulator handle
|
||||
* the clipboard locally. Requires TTY.
|
||||
*/
|
||||
export const writeOsc52 = (text: string): void => {
|
||||
if (!process.stdout.isTTY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(text).toString("base64");
|
||||
const osc52 = `${OSC52_SEQUENCE_PREFIX}${base64}${OSC52_SEQUENCE_SUFFIX}`;
|
||||
|
||||
const isPassthrough =
|
||||
Boolean(process.env[TMUX_ENV_VAR]) ||
|
||||
Boolean(process.env[SCREEN_ENV_VAR]);
|
||||
|
||||
const sequence = isPassthrough
|
||||
? `${TMUX_DCS_PREFIX}${osc52}${TMUX_DCS_SUFFIX}`
|
||||
: osc52;
|
||||
|
||||
process.stdout.write(sequence);
|
||||
};
|
||||
|
||||
/** Platform-specific copy method builders (object map dispatch) */
|
||||
const copyMethodBuilders: Record<string, () => CopyMethod | null> = {
|
||||
darwin: () => {
|
||||
if (!commandExists("osascript")) {
|
||||
return null;
|
||||
}
|
||||
return async (text: string): Promise<void> => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
await runCommandText("osascript", [
|
||||
"-e",
|
||||
`set the clipboard to "${escaped}"`,
|
||||
]);
|
||||
};
|
||||
},
|
||||
|
||||
linux: () => {
|
||||
if (process.env[WAYLAND_DISPLAY_ENV_VAR] && commandExists("wl-copy")) {
|
||||
return async (text: string): Promise<void> => {
|
||||
await runCommandWithStdin("wl-copy", [], text);
|
||||
};
|
||||
}
|
||||
if (commandExists("xclip")) {
|
||||
return async (text: string): Promise<void> => {
|
||||
await runCommandWithStdin(
|
||||
"xclip",
|
||||
["-selection", "clipboard"],
|
||||
text,
|
||||
);
|
||||
};
|
||||
}
|
||||
if (commandExists("xsel")) {
|
||||
return async (text: string): Promise<void> => {
|
||||
await runCommandWithStdin(
|
||||
"xsel",
|
||||
["--clipboard", "--input"],
|
||||
text,
|
||||
);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
win32: () => {
|
||||
return async (text: string): Promise<void> => {
|
||||
await runCommandWithStdin(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
text,
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Lazily resolved platform copy method (cached after first call) */
|
||||
let cachedCopyMethod: CopyMethod | null = null;
|
||||
let copyMethodResolved = false;
|
||||
|
||||
const resolveCopyMethod = (): CopyMethod | null => {
|
||||
if (copyMethodResolved) {
|
||||
return cachedCopyMethod;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const builder = copyMethodBuilders[platform];
|
||||
cachedCopyMethod = builder ? builder() : null;
|
||||
copyMethodResolved = true;
|
||||
|
||||
return cachedCopyMethod;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy text to system clipboard.
|
||||
* Always writes OSC 52 for SSH/remote support,
|
||||
* then uses platform-native copy if available.
|
||||
*/
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
writeOsc52(text);
|
||||
|
||||
const method = resolveCopyMethod();
|
||||
if (method) {
|
||||
await method(text);
|
||||
}
|
||||
};
|
||||
|
||||
/** Platform-specific text read commands (object map dispatch) */
|
||||
const textReadCommands: Record<string, () => Promise<string | null>> = {
|
||||
darwin: async (): Promise<string | null> => {
|
||||
if (!commandExists("pbpaste")) {
|
||||
return null;
|
||||
}
|
||||
const text = await runCommandText("pbpaste", []);
|
||||
return text || null;
|
||||
},
|
||||
|
||||
linux: async (): Promise<string | null> => {
|
||||
if (process.env[WAYLAND_DISPLAY_ENV_VAR] && commandExists("wl-paste")) {
|
||||
const text = await runCommandText("wl-paste", ["--no-newline"]);
|
||||
return text || null;
|
||||
}
|
||||
if (commandExists("xclip")) {
|
||||
const text = await runCommandText("xclip", [
|
||||
"-selection",
|
||||
"clipboard",
|
||||
"-o",
|
||||
]);
|
||||
return text || null;
|
||||
}
|
||||
if (commandExists("xsel")) {
|
||||
const text = await runCommandText("xsel", [
|
||||
"--clipboard",
|
||||
"--output",
|
||||
]);
|
||||
return text || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
win32: async (): Promise<string | null> => {
|
||||
const text = await runCommandText("powershell.exe", [
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-Clipboard",
|
||||
]);
|
||||
return text || null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Read text from system clipboard.
|
||||
* Returns null if clipboard is empty or no native tool is available.
|
||||
*/
|
||||
export const readClipboardText = async (): Promise<string | null> => {
|
||||
const platform = process.platform;
|
||||
const reader = textReadCommands[platform];
|
||||
|
||||
if (!reader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await reader();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
289
src/services/reasoning/thinking-parser.ts
Normal file
289
src/services/reasoning/thinking-parser.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Thinking Parser
|
||||
*
|
||||
* Stateful streaming parser that strips XML thinking/reasoning tags
|
||||
* from LLM streaming output. Content inside these tags is accumulated
|
||||
* separately and emitted as thinking events.
|
||||
*
|
||||
* Handles partial chunks gracefully - if a chunk ends mid-tag (e.g. "<thin"),
|
||||
* the parser buffers it until the next chunk arrives.
|
||||
*/
|
||||
|
||||
import {
|
||||
THINKING_TAGS,
|
||||
THINKING_BUFFER_MAX_SIZE,
|
||||
} from "@constants/thinking-tags";
|
||||
import type { ThinkingTagName } from "@constants/thinking-tags";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ThinkingParserResult {
|
||||
/** Visible content to display (tags stripped) */
|
||||
visible: string;
|
||||
/** Completed thinking block content, or null if none completed */
|
||||
thinking: string | null;
|
||||
}
|
||||
|
||||
interface ThinkingParser {
|
||||
/** Feed a streaming chunk through the parser */
|
||||
feed: (chunk: string) => ThinkingParserResult;
|
||||
/** Flush any remaining buffered content */
|
||||
flush: () => ThinkingParserResult;
|
||||
/** Reset parser state for a new streaming session */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
type ParserState = "normal" | "buffering_tag" | "inside_tag";
|
||||
|
||||
// =============================================================================
|
||||
// Tag Detection
|
||||
// =============================================================================
|
||||
|
||||
const TAG_SET = new Set<string>(THINKING_TAGS);
|
||||
|
||||
const isKnownTag = (name: string): name is ThinkingTagName =>
|
||||
TAG_SET.has(name);
|
||||
|
||||
/**
|
||||
* Try to match an opening tag at a given position in the text.
|
||||
* Returns the tag name and end index, or null if no match.
|
||||
*/
|
||||
const matchOpenTag = (
|
||||
text: string,
|
||||
startIndex: number,
|
||||
): { tag: ThinkingTagName; endIndex: number } | null => {
|
||||
for (const tag of THINKING_TAGS) {
|
||||
const pattern = `<${tag}>`;
|
||||
if (text.startsWith(pattern, startIndex)) {
|
||||
return { tag, endIndex: startIndex + pattern.length };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to match a closing tag for a specific tag name.
|
||||
* Returns the end index, or -1 if not found.
|
||||
*/
|
||||
const findCloseTag = (text: string, tagName: string): number => {
|
||||
const pattern = `</${tagName}>`;
|
||||
const index = text.indexOf(pattern);
|
||||
if (index === -1) return -1;
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if text could be the start of a known opening tag.
|
||||
* E.g. "<th" could be the start of "<thinking>".
|
||||
*/
|
||||
const couldBeTagStart = (text: string): boolean => {
|
||||
if (!text.startsWith("<")) return false;
|
||||
|
||||
const afterBracket = text.slice(1);
|
||||
|
||||
for (const tag of THINKING_TAGS) {
|
||||
const fullOpen = `${tag}>`;
|
||||
if (fullOpen.startsWith(afterBracket)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Parser Factory
|
||||
// =============================================================================
|
||||
|
||||
export const createThinkingParser = (): ThinkingParser => {
|
||||
let state: ParserState = "normal";
|
||||
let buffer = "";
|
||||
let thinkingContent = "";
|
||||
let currentTag: ThinkingTagName | null = null;
|
||||
|
||||
const makeResult = (
|
||||
visible: string,
|
||||
thinking: string | null,
|
||||
): ThinkingParserResult => ({
|
||||
visible,
|
||||
thinking,
|
||||
});
|
||||
|
||||
const processNormal = (text: string): ThinkingParserResult => {
|
||||
let visible = "";
|
||||
let completedThinking: string | null = null;
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
if (text[i] === "<") {
|
||||
const openMatch = matchOpenTag(text, i);
|
||||
|
||||
if (openMatch) {
|
||||
state = "inside_tag";
|
||||
currentTag = openMatch.tag;
|
||||
thinkingContent = "";
|
||||
i = openMatch.endIndex;
|
||||
// Process the rest as inside_tag
|
||||
const remaining = text.slice(i);
|
||||
if (remaining.length > 0) {
|
||||
const innerResult = processInsideTag(remaining);
|
||||
completedThinking = innerResult.thinking;
|
||||
visible += innerResult.visible;
|
||||
}
|
||||
return makeResult(visible, completedThinking);
|
||||
}
|
||||
|
||||
// Check if this could be the start of a tag (partial)
|
||||
const remainder = text.slice(i);
|
||||
if (couldBeTagStart(remainder) && remainder.length < longestOpenTag()) {
|
||||
// Buffer this partial potential tag
|
||||
state = "buffering_tag";
|
||||
buffer = remainder;
|
||||
return makeResult(visible, completedThinking);
|
||||
}
|
||||
|
||||
// Not a known tag, emit the '<' as visible
|
||||
visible += "<";
|
||||
i++;
|
||||
} else {
|
||||
visible += text[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return makeResult(visible, completedThinking);
|
||||
};
|
||||
|
||||
const processBufferingTag = (text: string): ThinkingParserResult => {
|
||||
buffer += text;
|
||||
|
||||
// Check if we now have a complete opening tag
|
||||
const openMatch = matchOpenTag(buffer, 0);
|
||||
if (openMatch) {
|
||||
state = "inside_tag";
|
||||
currentTag = openMatch.tag;
|
||||
thinkingContent = "";
|
||||
const remaining = buffer.slice(openMatch.endIndex);
|
||||
buffer = "";
|
||||
if (remaining.length > 0) {
|
||||
return processInsideTag(remaining);
|
||||
}
|
||||
return makeResult("", null);
|
||||
}
|
||||
|
||||
// Check if it can still become a valid tag
|
||||
if (couldBeTagStart(buffer) && buffer.length < longestOpenTag()) {
|
||||
// Still buffering
|
||||
return makeResult("", null);
|
||||
}
|
||||
|
||||
// Safety: if buffer exceeds max size, flush as visible
|
||||
if (buffer.length >= THINKING_BUFFER_MAX_SIZE) {
|
||||
const flushed = buffer;
|
||||
buffer = "";
|
||||
state = "normal";
|
||||
return makeResult(flushed, null);
|
||||
}
|
||||
|
||||
// Not a valid tag start, emit buffer as visible and process remaining
|
||||
const flushedBuffer = buffer;
|
||||
buffer = "";
|
||||
state = "normal";
|
||||
|
||||
// Re-process what was in the buffer (minus the first char which is '<')
|
||||
// since the rest might contain another '<'
|
||||
if (flushedBuffer.length > 1) {
|
||||
const result = processNormal(flushedBuffer.slice(1));
|
||||
return makeResult("<" + result.visible, result.thinking);
|
||||
}
|
||||
|
||||
return makeResult(flushedBuffer, null);
|
||||
};
|
||||
|
||||
const processInsideTag = (text: string): ThinkingParserResult => {
|
||||
if (!currentTag) {
|
||||
state = "normal";
|
||||
return processNormal(text);
|
||||
}
|
||||
|
||||
const closeIndex = findCloseTag(text, currentTag);
|
||||
|
||||
if (closeIndex === -1) {
|
||||
// No closing tag yet, accumulate as thinking content
|
||||
thinkingContent += text;
|
||||
return makeResult("", null);
|
||||
}
|
||||
|
||||
// Found closing tag
|
||||
thinkingContent += text.slice(0, closeIndex);
|
||||
const closingTagLength = `</${currentTag}>`.length;
|
||||
const afterClose = text.slice(closeIndex + closingTagLength);
|
||||
|
||||
const completedThinking = thinkingContent.trim();
|
||||
thinkingContent = "";
|
||||
currentTag = null;
|
||||
state = "normal";
|
||||
|
||||
// Process remaining content after the closing tag
|
||||
if (afterClose.length > 0) {
|
||||
const result = processNormal(afterClose);
|
||||
return makeResult(
|
||||
result.visible,
|
||||
completedThinking || result.thinking,
|
||||
);
|
||||
}
|
||||
|
||||
return makeResult("", completedThinking || null);
|
||||
};
|
||||
|
||||
const longestOpenTag = (): number => {
|
||||
let max = 0;
|
||||
for (const tag of THINKING_TAGS) {
|
||||
const len = tag.length + 2; // < + tag + >
|
||||
if (len > max) max = len;
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const stateHandlers: Record<
|
||||
ParserState,
|
||||
(text: string) => ThinkingParserResult
|
||||
> = {
|
||||
normal: processNormal,
|
||||
buffering_tag: processBufferingTag,
|
||||
inside_tag: processInsideTag,
|
||||
};
|
||||
|
||||
const feed = (chunk: string): ThinkingParserResult => {
|
||||
if (chunk.length === 0) return makeResult("", null);
|
||||
return stateHandlers[state](chunk);
|
||||
};
|
||||
|
||||
const flush = (): ThinkingParserResult => {
|
||||
if (state === "buffering_tag" && buffer.length > 0) {
|
||||
const flushed = buffer;
|
||||
buffer = "";
|
||||
state = "normal";
|
||||
return makeResult(flushed, null);
|
||||
}
|
||||
|
||||
if (state === "inside_tag" && thinkingContent.length > 0) {
|
||||
const thinking = thinkingContent.trim();
|
||||
thinkingContent = "";
|
||||
currentTag = null;
|
||||
state = "normal";
|
||||
return makeResult("", thinking || null);
|
||||
}
|
||||
|
||||
return makeResult("", null);
|
||||
};
|
||||
|
||||
const reset = (): void => {
|
||||
state = "normal";
|
||||
buffer = "";
|
||||
thinkingContent = "";
|
||||
currentTag = null;
|
||||
};
|
||||
|
||||
return { feed, flush, reset };
|
||||
};
|
||||
Reference in New Issue
Block a user