feat: add text clipboard copy/read with mouse selection support

Add cross-platform text clipboard operations (macOS, Linux, Windows)
with OSC 52 support for SSH/tmux environments. Wire up onMouseUp and
Ctrl+Y in the TUI to copy selected text to the system clipboard via
OpenTUI's renderer selection API.
This commit is contained in:
2026-02-06 11:01:08 -05:00
parent 8adf48abd3
commit 101300b103
17 changed files with 983 additions and 87 deletions

View File

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

View File

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