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( + ``, +); + +/** 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 = ``; + 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 = ``.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 + } }; /**