feat: windows selection behavior, manual ctrl+c (#13315)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { Selection } from "@tui/util/selection"
|
||||||
|
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||||
@@ -210,6 +211,35 @@ function App() {
|
|||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
|
if (!renderer.getSelection()) return
|
||||||
|
|
||||||
|
// Windows Terminal-like behavior:
|
||||||
|
// - Ctrl+C copies and dismisses selection
|
||||||
|
// - Esc dismisses selection
|
||||||
|
// - Most other key input dismisses selection and is passed through
|
||||||
|
if (evt.ctrl && evt.name === "c") {
|
||||||
|
if (!Selection.copy(renderer, toast)) {
|
||||||
|
renderer.clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.name === "escape") {
|
||||||
|
renderer.clearSelection()
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.clearSelection()
|
||||||
|
})
|
||||||
|
|
||||||
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
||||||
renderer.console.onCopySelection = async (text: string) => {
|
renderer.console.onCopySelection = async (text: string) => {
|
||||||
if (!text || text.length === 0) return
|
if (!text || text.length === 0) return
|
||||||
@@ -217,6 +247,7 @@ function App() {
|
|||||||
await Clipboard.copy(text)
|
await Clipboard.copy(text)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
}
|
}
|
||||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||||
@@ -703,19 +734,15 @@ function App() {
|
|||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
height={dimensions().height}
|
height={dimensions().height}
|
||||||
backgroundColor={theme.background}
|
backgroundColor={theme.background}
|
||||||
onMouseUp={async () => {
|
onMouseDown={(evt) => {
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
renderer.clearSelection()
|
if (evt.button !== MouseButton.RIGHT) return
|
||||||
return
|
|
||||||
}
|
if (!Selection.copy(renderer, toast)) return
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
evt.preventDefault()
|
||||||
if (text && text.length > 0) {
|
evt.stopPropagation()
|
||||||
await Clipboard.copy(text)
|
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
|
||||||
.catch(toast.error)
|
|
||||||
renderer.clearSelection()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={route.data.type === "home"}>
|
<Match when={route.data.type === "home"}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { Renderable, RGBA } from "@opentui/core"
|
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
|
||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Selection } from "@tui/util/selection"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -16,10 +17,18 @@ export function Dialog(
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
|
let dismiss = false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
onMouseUp={async () => {
|
onMouseDown={() => {
|
||||||
if (renderer.getSelection()) return
|
dismiss = !!renderer.getSelection()
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (dismiss) {
|
||||||
|
dismiss = false
|
||||||
|
return
|
||||||
|
}
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
}}
|
}}
|
||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
@@ -32,8 +41,8 @@ export function Dialog(
|
|||||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
onMouseUp={async (e) => {
|
onMouseUp={(e) => {
|
||||||
if (renderer.getSelection()) return
|
dismiss = false
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
width={props.size === "large" ? 80 : 60}
|
width={props.size === "large" ? 80 : 60}
|
||||||
@@ -56,8 +65,13 @@ function init() {
|
|||||||
size: "medium" as "medium" | "large",
|
size: "medium" as "medium" | "large",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderer = useRenderer()
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
|
if (store.stack.length === 0) return
|
||||||
|
if (evt.defaultPrevented) return
|
||||||
|
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||||
|
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
current.onClose?.()
|
current.onClose?.()
|
||||||
setStore("stack", store.stack.slice(0, -1))
|
setStore("stack", store.stack.slice(0, -1))
|
||||||
@@ -67,7 +81,6 @@ function init() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
|
||||||
let focus: Renderable | null
|
let focus: Renderable | null
|
||||||
function refocus() {
|
function refocus() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
|
|||||||
{props.children}
|
{props.children}
|
||||||
<box
|
<box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
onMouseUp={async () => {
|
onMouseDown={(evt) => {
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
if (text && text.length > 0) {
|
if (evt.button !== MouseButton.RIGHT) return
|
||||||
await Clipboard.copy(text)
|
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
if (!Selection.copy(renderer, toast)) return
|
||||||
.catch(toast.error)
|
evt.preventDefault()
|
||||||
renderer.clearSelection()
|
evt.stopPropagation()
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onMouseUp={
|
||||||
|
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show when={value.stack.length}>
|
<Show when={value.stack.length}>
|
||||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||||
|
|||||||
25
packages/opencode/src/cli/cmd/tui/util/selection.ts
Normal file
25
packages/opencode/src/cli/cmd/tui/util/selection.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Clipboard } from "./clipboard"
|
||||||
|
|
||||||
|
type Toast = {
|
||||||
|
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||||
|
error: (err: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Renderer = {
|
||||||
|
getSelection: () => { getSelectedText: () => string } | null
|
||||||
|
clearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Selection {
|
||||||
|
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||||
|
const text = renderer.getSelection()?.getSelectedText()
|
||||||
|
if (!text) return false
|
||||||
|
|
||||||
|
Clipboard.copy(text)
|
||||||
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
|
.catch(toast.error)
|
||||||
|
|
||||||
|
renderer.clearSelection()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
function truthyValue(value: string | undefined) {
|
||||||
|
const v = value?.toLowerCase()
|
||||||
|
return v === "true" || v === "1"
|
||||||
|
}
|
||||||
|
|
||||||
function truthy(key: string) {
|
function truthy(key: string) {
|
||||||
const value = process.env[key]?.toLowerCase()
|
return truthyValue(process.env[key])
|
||||||
return value === "true" || value === "1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Flag {
|
export namespace Flag {
|
||||||
@@ -37,7 +41,9 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
|
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
|
||||||
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
|
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
|
||||||
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
|
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
|
||||||
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
|
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||||
|
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
|
||||||
|
copy === undefined ? process.platform === "win32" : truthyValue(copy)
|
||||||
export const OPENCODE_ENABLE_EXA =
|
export const OPENCODE_ENABLE_EXA =
|
||||||
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
|
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
|
||||||
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
|
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
|
||||||
|
|||||||
Reference in New Issue
Block a user