diff --git a/package.json b/package.json
index dba7edb..bade557 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/constants/clipboard.ts b/src/constants/clipboard.ts
new file mode 100644
index 0000000..03cf4d5
--- /dev/null
+++ b/src/constants/clipboard.ts
@@ -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;
diff --git a/src/constants/mouse-handler.ts b/src/constants/mouse-handler.ts
index a0583cc..af473d5 100644
--- a/src/constants/mouse-handler.ts
+++ b/src/constants/mouse-handler.ts
@@ -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";
diff --git a/src/constants/plan-approval.ts b/src/constants/plan-approval.ts
new file mode 100644
index 0000000..b025627
--- /dev/null
+++ b/src/constants/plan-approval.ts
@@ -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";
diff --git a/src/constants/terminal.ts b/src/constants/terminal.ts
index dbe8c35..8b9566f 100644
--- a/src/constants/terminal.ts
+++ b/src/constants/terminal.ts
@@ -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
diff --git a/src/constants/thinking-tags.ts b/src/constants/thinking-tags.ts
new file mode 100644
index 0000000..2a7e6fa
--- /dev/null
+++ b/src/constants/thinking-tags.ts
@@ -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: , , etc. */
+export const THINKING_OPEN_TAG_REGEX = new RegExp(
+ `<(${THINKING_TAGS.join("|")})>`,
+);
+
+/** Regex pattern to match a complete closing tag: , , 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;
diff --git a/src/services/chat-tui/streaming.ts b/src/services/chat-tui/streaming.ts
index 6c725bd..ac1e992 100644
--- a/src/services/chat-tui/streaming.ts
+++ b/src/services/chat-tui/streaming.ts
@@ -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,
-): 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;
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();
diff --git a/src/services/clipboard-service.ts b/src/services/clipboard-service.ts
index 03258ad..eddd433 100644
--- a/src/services/clipboard-service.ts
+++ b/src/services/clipboard-service.ts
@@ -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 (
diff --git a/src/services/clipboard/read-clipboard.ts b/src/services/clipboard/read-clipboard.ts
new file mode 100644
index 0000000..31e45fc
--- /dev/null
+++ b/src/services/clipboard/read-clipboard.ts
@@ -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 => {
+ 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;
+};
diff --git a/src/services/clipboard/run-command.ts b/src/services/clipboard/run-command.ts
new file mode 100644
index 0000000..88d1cf0
--- /dev/null
+++ b/src/services/clipboard/run-command.ts
@@ -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 => {
+ 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 => {
+ 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;
+ }
+};
diff --git a/src/services/clipboard/text-clipboard.ts b/src/services/clipboard/text-clipboard.ts
new file mode 100644
index 0000000..d22c5c0
--- /dev/null
+++ b/src/services/clipboard/text-clipboard.ts
@@ -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 CopyMethod | null> = {
+ darwin: () => {
+ if (!commandExists("osascript")) {
+ return null;
+ }
+ return async (text: string): Promise => {
+ 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 => {
+ await runCommandWithStdin("wl-copy", [], text);
+ };
+ }
+ if (commandExists("xclip")) {
+ return async (text: string): Promise => {
+ await runCommandWithStdin(
+ "xclip",
+ ["-selection", "clipboard"],
+ text,
+ );
+ };
+ }
+ if (commandExists("xsel")) {
+ return async (text: string): Promise => {
+ await runCommandWithStdin(
+ "xsel",
+ ["--clipboard", "--input"],
+ text,
+ );
+ };
+ }
+ return null;
+ },
+
+ win32: () => {
+ return async (text: string): Promise => {
+ 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 => {
+ writeOsc52(text);
+
+ const method = resolveCopyMethod();
+ if (method) {
+ await method(text);
+ }
+};
+
+/** Platform-specific text read commands (object map dispatch) */
+const textReadCommands: Record Promise> = {
+ darwin: async (): Promise => {
+ if (!commandExists("pbpaste")) {
+ return null;
+ }
+ const text = await runCommandText("pbpaste", []);
+ return text || null;
+ },
+
+ linux: async (): Promise => {
+ 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 => {
+ 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 => {
+ const platform = process.platform;
+ const reader = textReadCommands[platform];
+
+ if (!reader) {
+ return null;
+ }
+
+ try {
+ return await reader();
+ } catch {
+ return null;
+ }
+};
diff --git a/src/services/reasoning/thinking-parser.ts b/src/services/reasoning/thinking-parser.ts
new file mode 100644
index 0000000..e62b78a
--- /dev/null
+++ b/src/services/reasoning/thinking-parser.ts
@@ -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. " 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(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. "| ".
+ */
+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 };
+};
diff --git a/src/tui-solid/app.tsx b/src/tui-solid/app.tsx
index d53f6a5..a5dd6a4 100644
--- a/src/tui-solid/app.tsx
+++ b/src/tui-solid/app.tsx
@@ -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;
onCascadeToggle?: (enabled: boolean) => Promise;
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([]);
setAppStoreRef(app);
+ /** Copy selected text to clipboard and clear selection */
+ const copySelectionToClipboard = async (): Promise => {
+ 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()}
>
@@ -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;
onCascadeToggle?: (enabled: boolean) => Promise;
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 {
return new Promise((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);
};
diff --git a/src/tui-solid/context/app.tsx b/src/tui-solid/context/app.tsx
index 9f7a4eb..a32904b 100644
--- a/src/tui-solid/context/app.tsx
+++ b/src/tui-solid/context/app.tsx
@@ -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;
currentToolCall: Accessor;
permissionRequest: Accessor;
+ planApprovalPrompt: Accessor;
learningPrompt: Accessor;
thinkingMessage: Accessor;
sessionId: Accessor;
@@ -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);
diff --git a/src/types/clipboard.ts b/src/types/clipboard.ts
new file mode 100644
index 0000000..445133d
--- /dev/null
+++ b/src/types/clipboard.ts
@@ -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;
diff --git a/src/types/tui.ts b/src/types/tui.ts
index ae47e15..cebcfd5 100644
--- a/src/types/tui.ts
+++ b/src/types/tui.ts
@@ -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
// ============================================================================
diff --git a/src/utils/core/terminal.ts b/src/utils/core/terminal.ts
index 83dc97f..7124449 100644
--- a/src/utils/core/terminal.ts
+++ b/src/utils/core/terminal.ts
@@ -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
+ }
};
/**
|