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