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:
2026-02-06 11:01:08 -05:00
parent 8adf48abd3
commit 101300b103
17 changed files with 983 additions and 87 deletions

View File

@@ -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();

View File

@@ -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 (

View 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;
};

View 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;
}
};

View 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;
}
};

View 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 };
};