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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user