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

@@ -11,8 +11,8 @@
"dev": "bun src/index.ts",
"dev:nobump": "bun scripts/build.ts && npm link",
"dev:watch": "bun scripts/dev-watch.ts",
"dev:debug": "bun --inspect=localhost:6499 src/index.ts",
"dev:debug-brk": "bun --inspect-brk=localhost:6499 src/index.ts",
"dev:debug": "bun --inspect=localhost:6499/debug src/index.ts",
"dev:debug-brk": "bun --inspect-brk=localhost:6499/debug src/index.ts",
"build": "bun scripts/build.ts",
"sync-version": "bun scripts/sync-version.ts",
"start": "bun src/index.ts",

View File

@@ -0,0 +1,23 @@
/**
* Clipboard constants for text copy/read operations
*/
/** OSC 52 escape sequence for clipboard write (insert base64 payload) */
export const OSC52_SEQUENCE_PREFIX = "\x1b]52;c;";
export const OSC52_SEQUENCE_SUFFIX = "\x07";
/** DCS tmux passthrough wrapping */
export const TMUX_DCS_PREFIX = "\x1bPtmux;\x1b";
export const TMUX_DCS_SUFFIX = "\x1b\\";
/** Environment variables indicating tmux/screen session */
export const TMUX_ENV_VAR = "TMUX";
export const SCREEN_ENV_VAR = "STY";
export const WAYLAND_DISPLAY_ENV_VAR = "WAYLAND_DISPLAY";
/** Platform command timeout in milliseconds */
export const CLIPBOARD_COMMAND_TIMEOUT_MS = 5000;
/** Supported MIME types for clipboard content */
export const CLIPBOARD_MIME_TEXT = "text/plain" as const;
export const CLIPBOARD_MIME_IMAGE_PNG = "image/png" as const;

View File

@@ -26,11 +26,38 @@ export const PARTIAL_X10_REGEX = /\x1b\[M.{0,2}$/;
// Mouse tracking escape sequences
export const MOUSE_TRACKING_SEQUENCES = {
ENABLE_BUTTON: "\x1b[?1000h",
ENABLE_BUTTON_EVENT: "\x1b[?1002h",
ENABLE_SGR: "\x1b[?1006h",
DISABLE_SGR: "\x1b[?1006l",
DISABLE_BUTTON_EVENT: "\x1b[?1002l",
DISABLE_BUTTON: "\x1b[?1000l",
} as const;
// Enable button-event tracking + SGR encoding (for scroll + drag selection)
export const MOUSE_TRACKING_ENABLE =
MOUSE_TRACKING_SEQUENCES.ENABLE_BUTTON_EVENT +
MOUSE_TRACKING_SEQUENCES.ENABLE_SGR;
// Mouse button action codes (SGR encoding)
export const MOUSE_BUTTON = {
LEFT_PRESS: 0,
MIDDLE_PRESS: 1,
RIGHT_PRESS: 2,
LEFT_DRAG: 32,
MIDDLE_DRAG: 33,
RIGHT_DRAG: 34,
SCROLL_UP: 64,
SCROLL_DOWN: 65,
} as const;
// Disable button-event tracking + SGR encoding
export const MOUSE_TRACKING_DISABLE =
MOUSE_TRACKING_SEQUENCES.DISABLE_BUTTON_EVENT +
MOUSE_TRACKING_SEQUENCES.DISABLE_SGR;
// Time in ms to re-enable mouse tracking after selection ends
export const MOUSE_SELECTION_REENABLE_MS = 2000;
// Scroll direction type
export type MouseScrollDirection = "up" | "down";

View File

@@ -0,0 +1,58 @@
/**
* Plan Approval Constants
*
* Options and configuration for the plan approval prompt,
* modeled after Claude Code's plan approval flow.
*/
/** How edits should be handled after plan approval */
export type PlanEditMode =
| "auto_accept_clear" // Clear context and auto-accept edits
| "auto_accept" // Auto-accept edits
| "manual_approve" // Manually approve each edit
| "feedback"; // User provides feedback/changes
/** A selectable option in the plan approval modal */
export interface PlanApprovalOption {
key: string;
label: string;
description: string;
editMode: PlanEditMode;
shortcut?: string;
}
/** Available plan approval options, matching Claude Code's pattern */
export const PLAN_APPROVAL_OPTIONS: PlanApprovalOption[] = [
{
key: "1",
label: "Yes, clear context and auto-accept edits",
description: "Approve plan, clear conversation context, and auto-accept all file edits",
editMode: "auto_accept_clear",
shortcut: "shift+tab",
},
{
key: "2",
label: "Yes, auto-accept edits",
description: "Approve plan and auto-accept all file edits without prompting",
editMode: "auto_accept",
},
{
key: "3",
label: "Yes, manually approve edits",
description: "Approve plan but require manual approval for each file edit",
editMode: "manual_approve",
},
{
key: "4",
label: "Type here to tell CodeTyper what to change",
description: "Provide feedback or modifications to the plan",
editMode: "feedback",
},
];
/** Maximum visible options before scrolling */
export const PLAN_APPROVAL_MAX_VISIBLE_OPTIONS = 4;
/** Footer help text for plan approval modal */
export const PLAN_APPROVAL_FOOTER_TEXT =
"ctrl-g to edit in editor";

View File

@@ -7,3 +7,14 @@ export const DISABLE_MOUSE_TRACKING =
"\x1b[?1003l" + // All mouse events (motion)
"\x1b[?1002l" + // Button event mouse tracking
"\x1b[?1000l"; // Normal tracking mode
/**
* Full terminal reset sequence for cleanup on exit
* Disables all tracking modes, restores cursor, exits alternate screen
*/
export const TERMINAL_RESET =
DISABLE_MOUSE_TRACKING +
"\x1b[?25h" + // Show cursor
"\x1b[?1049l" + // Leave alternate screen
"\x1b[>4;0m" + // Disable Kitty keyboard protocol (pop)
"\x1b[?2004l"; // Disable bracketed paste mode

View File

@@ -0,0 +1,30 @@
/**
* Thinking Tags Constants
*
* XML tag names that LLM providers use for reasoning/thinking blocks.
* These should be stripped from visible streaming output and displayed
* as dimmed thinking indicators instead.
*/
/** Tag names to strip from streaming content */
export const THINKING_TAGS = [
"thinking",
"search",
"plan",
"execute",
] as const;
export type ThinkingTagName = (typeof THINKING_TAGS)[number];
/** Regex pattern to match a complete opening tag: <thinking>, <search>, etc. */
export const THINKING_OPEN_TAG_REGEX = new RegExp(
`<(${THINKING_TAGS.join("|")})>`,
);
/** Regex pattern to match a complete closing tag: </thinking>, </search>, etc. */
export const THINKING_CLOSE_TAG_REGEX = new RegExp(
`</(${THINKING_TAGS.join("|")})>`,
);
/** Maximum buffer size before flushing as visible text (safety valve) */
export const THINKING_BUFFER_MAX_SIZE = 256;

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

View File

@@ -1,4 +1,4 @@
import { render, useKeyboard } from "@opentui/solid";
import { render, useKeyboard, useRenderer } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import {
ErrorBoundary,
@@ -16,7 +16,8 @@ import {
advanceStep,
getExecutionState,
} from "@services/chat-tui-service";
import { DISABLE_MOUSE_TRACKING } from "@constants/terminal";
import { TERMINAL_RESET } from "@constants/terminal";
import { copyToClipboard } from "@services/clipboard/text-clipboard";
import versionData from "@/version.json";
import { ExitProvider, useExit } from "@tui-solid/context/exit";
import { RouteProvider, useRoute } from "@tui-solid/context/route";
@@ -33,7 +34,11 @@ import { Home } from "@tui-solid/routes/home";
import { Session } from "@tui-solid/routes/session";
import type { TuiInput, TuiOutput } from "@interfaces/index";
import type { MCPServerDisplay } from "@/types/tui";
import type { PermissionScope, LearningScope } from "@/types/tui";
import type {
PermissionScope,
LearningScope,
PlanApprovalPromptResponse,
} from "@/types/tui";
import type { MCPAddFormData } from "@/types/mcp";
interface AgentOption {
@@ -55,6 +60,7 @@ interface AppProps extends TuiInput {
onProviderSelect?: (providerId: string) => Promise<void>;
onCascadeToggle?: (enabled: boolean) => Promise<void>;
onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void;
onPlanApprovalResponse: (response: PlanApprovalPromptResponse) => void;
onLearningResponse: (
save: boolean,
scope?: LearningScope,
@@ -104,10 +110,22 @@ function AppContent(props: AppProps) {
const exit = useExit();
const toast = useToast();
const theme = useTheme();
const renderer = useRenderer();
const [fileList, setFileList] = createSignal<string[]>([]);
setAppStoreRef(app);
/** Copy selected text to clipboard and clear selection */
const copySelectionToClipboard = async (): Promise<void> => {
const text = renderer.getSelection()?.getSelectedText();
if (text && text.length > 0) {
await copyToClipboard(text)
.then(() => toast.info("Copied to clipboard"))
.catch(() => toast.error("Failed to copy to clipboard"));
renderer.clearSelection();
}
};
// Load files when file_picker mode is activated
createEffect(() => {
if (app.mode() === "file_picker") {
@@ -164,6 +182,13 @@ function AppContent(props: AppProps) {
}
useKeyboard((evt) => {
// Ctrl+Y copies selected text to clipboard
if (evt.ctrl && evt.name === "y") {
copySelectionToClipboard();
evt.preventDefault();
return;
}
// ESC aborts current operation
if (evt.name === "escape") {
abortCurrentOperation(false).then((aborted) => {
@@ -347,6 +372,14 @@ function AppContent(props: AppProps) {
props.onPermissionResponse(allowed, scope);
};
const handlePlanApprovalResponse = (
response: PlanApprovalPromptResponse,
): void => {
// Don't set mode here - the resolve callback in plan-approval.ts
// handles the mode transition
props.onPlanApprovalResponse(response);
};
const handleLearningResponse = (
save: boolean,
scope?: LearningScope,
@@ -421,6 +454,7 @@ function AppContent(props: AppProps) {
flexDirection="column"
flexGrow={1}
backgroundColor={theme.colors.background}
onMouseUp={() => copySelectionToClipboard()}
>
<Switch>
<Match when={route.isHome()}>
@@ -446,6 +480,7 @@ function AppContent(props: AppProps) {
onProviderSelect={handleProviderSelect}
onCascadeToggle={handleCascadeToggle}
onPermissionResponse={handlePermissionResponse}
onPlanApprovalResponse={handlePlanApprovalResponse}
onLearningResponse={handleLearningResponse}
onBrainSetJwtToken={props.onBrainSetJwtToken}
onBrainSetApiKey={props.onBrainSetApiKey}
@@ -499,6 +534,7 @@ export interface TuiRenderOptions extends TuiInput {
onProviderSelect?: (providerId: string) => Promise<void>;
onCascadeToggle?: (enabled: boolean) => Promise<void>;
onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void;
onPlanApprovalResponse: (response: PlanApprovalPromptResponse) => void;
onLearningResponse: (
save: boolean,
scope?: LearningScope,
@@ -520,8 +556,14 @@ export interface TuiRenderOptions extends TuiInput {
export function tui(options: TuiRenderOptions): Promise<TuiOutput> {
return new Promise<TuiOutput>((resolve) => {
const { writeSync } = require("fs");
const handleExit = (output: TuiOutput): void => {
process.stdout.write(DISABLE_MOUSE_TRACKING);
try {
writeSync(1, TERMINAL_RESET);
} catch {
// Ignore - stdout may already be closed
}
resolve(output);
};

View File

@@ -8,6 +8,7 @@ import type {
LogEntry,
ToolCall,
PermissionRequest,
PlanApprovalPrompt,
LearningPrompt,
SessionStats,
SuggestionPrompt,
@@ -29,6 +30,7 @@ interface AppStore {
logs: LogEntry[];
currentToolCall: ToolCall | null;
permissionRequest: PermissionRequest | null;
planApprovalPrompt: PlanApprovalPrompt | null;
learningPrompt: LearningPrompt | null;
thinkingMessage: string | null;
sessionId: string | null;
@@ -71,6 +73,7 @@ interface AppContextValue {
logs: Accessor<LogEntry[]>;
currentToolCall: Accessor<ToolCall | null>;
permissionRequest: Accessor<PermissionRequest | null>;
planApprovalPrompt: Accessor<PlanApprovalPrompt | null>;
learningPrompt: Accessor<LearningPrompt | null>;
thinkingMessage: Accessor<string | null>;
sessionId: Accessor<string | null>;
@@ -125,6 +128,9 @@ interface AppContextValue {
// Permission actions
setPermissionRequest: (request: PermissionRequest | null) => void;
// Plan approval actions
setPlanApprovalPrompt: (prompt: PlanApprovalPrompt | null) => void;
// Learning prompt actions
setLearningPrompt: (prompt: LearningPrompt | null) => void;
@@ -243,6 +249,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
logs: [],
currentToolCall: null,
permissionRequest: null,
planApprovalPrompt: null,
learningPrompt: null,
thinkingMessage: null,
sessionId: null,
@@ -294,6 +301,8 @@ export const { provider: AppStoreProvider, use: useAppStore } =
const currentToolCall = (): ToolCall | null => store.currentToolCall;
const permissionRequest = (): PermissionRequest | null =>
store.permissionRequest;
const planApprovalPrompt = (): PlanApprovalPrompt | null =>
store.planApprovalPrompt;
const learningPrompt = (): LearningPrompt | null => store.learningPrompt;
const thinkingMessage = (): string | null => store.thinkingMessage;
const sessionId = (): string | null => store.sessionId;
@@ -419,6 +428,13 @@ export const { provider: AppStoreProvider, use: useAppStore } =
setStore("permissionRequest", request);
};
// Plan approval actions
const setPlanApprovalPrompt = (
prompt: PlanApprovalPrompt | null,
): void => {
setStore("planApprovalPrompt", prompt);
};
// Learning prompt actions
const setLearningPrompt = (prompt: LearningPrompt | null): void => {
setStore("learningPrompt", prompt);
@@ -775,7 +791,8 @@ export const { provider: AppStoreProvider, use: useAppStore } =
return (
store.mode === "thinking" ||
store.mode === "tool_execution" ||
store.mode === "permission_prompt"
store.mode === "permission_prompt" ||
store.mode === "plan_approval"
);
};
@@ -790,6 +807,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
logs,
currentToolCall,
permissionRequest,
planApprovalPrompt,
learningPrompt,
thinkingMessage,
sessionId,
@@ -840,6 +858,9 @@ export const { provider: AppStoreProvider, use: useAppStore } =
// Permission actions
setPermissionRequest,
// Plan approval actions
setPlanApprovalPrompt,
// Learning prompt actions
setLearningPrompt,
@@ -930,6 +951,7 @@ const defaultAppState = {
logs: [] as LogEntry[],
currentToolCall: null,
permissionRequest: null,
planApprovalPrompt: null,
learningPrompt: null,
thinkingMessage: null,
sessionId: null,
@@ -970,6 +992,7 @@ export const appStore = {
logs: storeRef.logs(),
currentToolCall: storeRef.currentToolCall(),
permissionRequest: storeRef.permissionRequest(),
planApprovalPrompt: storeRef.planApprovalPrompt(),
learningPrompt: storeRef.learningPrompt(),
thinkingMessage: storeRef.thinkingMessage(),
sessionId: storeRef.sessionId(),
@@ -1035,6 +1058,11 @@ export const appStore = {
storeRef.setPermissionRequest(request);
},
setPlanApprovalPrompt: (prompt: PlanApprovalPrompt | null): void => {
if (!storeRef) return;
storeRef.setPlanApprovalPrompt(prompt);
},
setLearningPrompt: (prompt: LearningPrompt | null): void => {
if (!storeRef) return;
storeRef.setLearningPrompt(prompt);

15
src/types/clipboard.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Clipboard types for text and image clipboard operations
*/
/** MIME types supported by clipboard operations */
export type ClipboardMime = "text/plain" | "image/png";
/** Content read from the clipboard */
export interface ClipboardContent {
data: string;
mime: ClipboardMime;
}
/** Platform-specific copy method signature */
export type CopyMethod = (text: string) => Promise<void>;

View File

@@ -17,6 +17,7 @@ export type AppMode =
| "thinking"
| "tool_execution"
| "permission_prompt"
| "plan_approval"
| "command_menu"
| "model_select"
| "agent_select"
@@ -203,6 +204,24 @@ export interface PermissionResponse {
scope?: PermissionScope;
}
// ============================================================================
// Plan Approval Types
// ============================================================================
export interface PlanApprovalPrompt {
id: string;
planTitle: string;
planSummary: string;
planFilePath?: string;
resolve: (response: PlanApprovalPromptResponse) => void;
}
export interface PlanApprovalPromptResponse {
approved: boolean;
editMode: "auto_accept_clear" | "auto_accept" | "manual_approve" | "feedback";
feedback?: string;
}
// ============================================================================
// Learning Types
// ============================================================================

View File

@@ -2,11 +2,12 @@
* Terminal UI helpers for formatting and display
*/
import { writeSync } from "fs";
import chalk from "chalk";
import ora, { Ora } from "ora";
import boxen from "boxen";
import { TERMINAL_SEQUENCES } from "@constants/ui";
import { DISABLE_MOUSE_TRACKING } from "@constants/terminal";
import { TERMINAL_RESET } from "@constants/terminal";
/**
* Spinner state
@@ -20,28 +21,27 @@ let exitHandlersRegistered = false;
/**
* Emergency cleanup for terminal state on process exit
* Uses writeSync to fd 1 (stdout) to guarantee bytes are flushed
* before the process terminates
*/
const emergencyTerminalCleanup = (): void => {
try {
process.stdout.write(
DISABLE_MOUSE_TRACKING +
TERMINAL_SEQUENCES.SHOW_CURSOR +
TERMINAL_SEQUENCES.LEAVE_ALTERNATE_SCREEN,
);
writeSync(1, TERMINAL_RESET);
} catch {
// TODO: Create a catch with a logger to log errors
// Ignore errors during cleanup
// Ignore errors during cleanup - stdout may already be closed
}
};
/**
* Register process exit handlers to ensure terminal cleanup
* Covers all exit paths: normal exit, signals, crashes, and unhandled rejections
*/
export const registerExitHandlers = (): void => {
if (exitHandlersRegistered) return;
exitHandlersRegistered = true;
process.on("exit", emergencyTerminalCleanup);
process.on("beforeExit", emergencyTerminalCleanup);
process.on("SIGINT", () => {
emergencyTerminalCleanup();
process.exit(130);
@@ -50,6 +50,18 @@ export const registerExitHandlers = (): void => {
emergencyTerminalCleanup();
process.exit(143);
});
process.on("SIGHUP", () => {
emergencyTerminalCleanup();
process.exit(128);
});
process.on("uncaughtException", () => {
emergencyTerminalCleanup();
process.exit(1);
});
process.on("unhandledRejection", () => {
emergencyTerminalCleanup();
process.exit(1);
});
};
/**
@@ -250,14 +262,14 @@ export const enterFullscreen = (): void => {
/**
* Exit fullscreen mode (restore main screen buffer)
* Disables all mouse tracking modes that might have been enabled
* Disables all mouse tracking modes and restores terminal state
*/
export const exitFullscreen = (): void => {
process.stdout.write(
DISABLE_MOUSE_TRACKING +
TERMINAL_SEQUENCES.SHOW_CURSOR +
TERMINAL_SEQUENCES.LEAVE_ALTERNATE_SCREEN,
);
try {
writeSync(1, TERMINAL_RESET);
} catch {
// Ignore errors - stdout may already be closed
}
};
/**