Skip to content

Commit e0f97ff

Browse files
committed
feat(tui): keep prompt cursor in place for pointer selections
1 parent ea9af38 commit e0f97ff

6 files changed

Lines changed: 300 additions & 6 deletions

File tree

‎packages/tui/src/component/prompt/index.tsx‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import { useConnected } from "../use-connected"
4949
import { useToast } from "../../ui/toast"
5050
import { createFadeIn } from "../../util/signal"
5151
import { DialogSkill } from "../dialog-skill"
52-
import { useConfig } from "../../config"
52+
import { Config, useConfig } from "../../config"
5353
import { usePromptMove } from "./move"
5454
import { promptFilesForServer, resolvePastedAttachments } from "./local-attachment"
5555
import { locationKey, useData } from "../../context/data"
@@ -72,6 +72,7 @@ import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
7272
import { truncateFilePath } from "../../ui/file-path"
7373
import { PromptMetadataRow } from "./metadata"
7474
import { limitLabel, useUsageLimits } from "../usage"
75+
import { createPointerSelection, type PointerSelection } from "./pointer-selection"
7576

7677
export type PromptProps = {
7778
sessionID?: string
@@ -186,6 +187,7 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
186187

187188
export function Prompt(props: PromptProps) {
188189
let input: TextareaRenderable
190+
let pointerSelection: PointerSelection | undefined
189191
let anchor: BoxRenderable
190192
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
191193

@@ -208,6 +210,7 @@ export function Prompt(props: PromptProps) {
208210
const currentLocation = useLocation()
209211
const args = useArgs()
210212
const config = useConfig().data
213+
const pointerSelectionEnabled = () => ["primary", "both"].includes(Config.copyMode(config))
211214
const dialog = useDialog()
212215
const toast = useToast()
213216
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
@@ -374,6 +377,9 @@ export function Prompt(props: PromptProps) {
374377
let pasteQueue = Promise.resolve()
375378

376379
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
380+
// A paste lands at the cursor; a mouse selection in the prompt only feeds the primary selection.
381+
pointerSelection?.flush()
382+
if (pointerSelection?.selected()) renderer.clearSelection()
377383
pasteQueue = pasteQueue
378384
.then(async () => {
379385
if (disposed || input.isDestroyed || disabled()) return
@@ -729,8 +735,12 @@ export function Prompt(props: PromptProps) {
729735
}
730736
})
731737

738+
// A key after a single click acts where the click placed the cursor, without waiting for a possible double click.
739+
onCleanup(keymap.intercept("key", () => pointerSelection?.flush(), { priority: 102 }))
740+
732741
onCleanup(() => {
733742
disposed = true
743+
pointerSelection?.dispose()
734744
if (store.prompt.text) {
735745
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
736746
}
@@ -1432,6 +1442,13 @@ export function Prompt(props: PromptProps) {
14321442
)
14331443
}
14341444

1445+
function expandPastedTextAtCursor() {
1446+
const extmark = input.extmarks
1447+
.getAtOffset(input.cursorOffset)
1448+
.find((item) => store.extmarkToPart.get(item.id)?.type === "pasted")
1449+
return !!extmark && expandPastedText(extmark.id)
1450+
}
1451+
14351452
function expandPastedText(extmarkId: number) {
14361453
const extmark = input.extmarks.get(extmarkId)
14371454
const ref = store.extmarkToPart.get(extmarkId)
@@ -1822,8 +1839,14 @@ export function Prompt(props: PromptProps) {
18221839
}}
18231840
ref={(r: TextareaRenderable) => {
18241841
input = r
1842+
pointerSelection = createPointerSelection(r, {
1843+
clock: renderer.clock,
1844+
enabled: pointerSelectionEnabled,
1845+
onClick: expandPastedTextAtCursor,
1846+
})
18251847
Object.assign(r, {
18261848
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted),
1849+
hasPointerSelection: () => pointerSelection?.selected() ?? false,
18271850
})
18281851
setInputTarget(r)
18291852
if (promptPartTypeId === 0) {
@@ -1849,10 +1872,8 @@ export function Prompt(props: PromptProps) {
18491872
})
18501873
} else if (r.button === MouseButton.LEFT) {
18511874
r.target?.focus()
1852-
const extmark = input.extmarks
1853-
.getAtOffset(input.cursorOffset)
1854-
.find((item) => store.extmarkToPart.get(item.id)?.type === "pasted")
1855-
if (extmark && expandPastedText(extmark.id)) {
1875+
// Pointer selections leave the cursor in place, so their click expands once it settles.
1876+
if (!pointerSelectionEnabled() && expandPastedTextAtCursor()) {
18561877
r.preventDefault()
18571878
r.stopPropagation()
18581879
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { Clock, TextareaRenderable, TimerHandle } from "@opentui/core"
2+
3+
// OpenTUI turns a click within this interval of the previous one into a word or line selection and does not export
4+
// the value. A single click places the cursor only once it can no longer become a double click.
5+
export const MULTI_CLICK_INTERVAL_MS = 500
6+
7+
// Keeps mouse selections in the prompt from moving its cursor, as with a terminal selection made for middle-click
8+
// paste. Callers drop the selection before typing or pasting so the input lands at the cursor instead of replacing it.
9+
export function createPointerSelection(
10+
input: TextareaRenderable,
11+
options: { clock: Clock; enabled: () => boolean; onClick: () => void },
12+
) {
13+
const view = input.editorView
14+
const setLocalSelection = view.setLocalSelection.bind(view)
15+
const updateLocalSelection = view.updateLocalSelection.bind(view)
16+
const resetLocalSelection = view.resetLocalSelection.bind(view)
17+
const onSelectionChanged = input.onSelectionChanged.bind(input)
18+
const state: {
19+
selected: boolean
20+
pointer?: { x: number; y: number; moved: boolean }
21+
click?: { x: number; y: number }
22+
timer?: TimerHandle
23+
} = { selected: false }
24+
25+
// OpenTUI asks the view to move the cursor on every selection change, and to follow it only while the keyboard
26+
// selects, so a cursor update without follow comes from the mouse.
27+
const track = (
28+
anchorX: number,
29+
anchorY: number,
30+
focusX: number,
31+
focusY: number,
32+
updateCursor?: boolean,
33+
followCursor?: boolean,
34+
) => {
35+
if (!updateCursor || followCursor || !options.enabled()) return updateCursor
36+
state.pointer = { x: anchorX, y: anchorY, moved: anchorX !== focusX || anchorY !== focusY }
37+
return false
38+
}
39+
view.setLocalSelection = (anchorX, anchorY, focusX, focusY, bgColor, fgColor, updateCursor, followCursor, behavior) =>
40+
setLocalSelection(
41+
anchorX,
42+
anchorY,
43+
focusX,
44+
focusY,
45+
bgColor,
46+
fgColor,
47+
track(anchorX, anchorY, focusX, focusY, updateCursor, followCursor),
48+
followCursor,
49+
behavior,
50+
)
51+
view.updateLocalSelection = (
52+
anchorX,
53+
anchorY,
54+
focusX,
55+
focusY,
56+
bgColor,
57+
fgColor,
58+
updateCursor,
59+
followCursor,
60+
behavior,
61+
) =>
62+
updateLocalSelection(
63+
anchorX,
64+
anchorY,
65+
focusX,
66+
focusY,
67+
bgColor,
68+
fgColor,
69+
track(anchorX, anchorY, focusX, focusY, updateCursor, followCursor),
70+
followCursor,
71+
behavior,
72+
)
73+
74+
input.onSelectionChanged = (selection) => {
75+
takePointer()
76+
const selected = onSelectionChanged(selection)
77+
const pointer = takePointer()
78+
state.selected = selected && pointer !== undefined
79+
if (!selection || !pointer) return selected
80+
if (pointer.moved || selection.behavior !== "cell") cancel()
81+
else if (selection.isStart && selection.isDragging) {
82+
stop()
83+
state.click = { x: pointer.x, y: pointer.y }
84+
} else if (!selection.isDragging && state.click) {
85+
stop()
86+
state.timer = options.clock.setTimeout(place, MULTI_CLICK_INTERVAL_MS)
87+
}
88+
return selected
89+
}
90+
91+
function takePointer() {
92+
const pointer = state.pointer
93+
state.pointer = undefined
94+
return pointer
95+
}
96+
97+
function place() {
98+
stop()
99+
const click = state.click
100+
state.click = undefined
101+
if (!click || input.isDestroyed) return
102+
// The view already holds this empty selection without a cursor update; resetting it makes the view apply one.
103+
resetLocalSelection()
104+
setLocalSelection(click.x, click.y, click.x, click.y, undefined, undefined, true, false, "cell")
105+
input.requestRender()
106+
options.onClick()
107+
}
108+
109+
function cancel() {
110+
stop()
111+
state.click = undefined
112+
}
113+
114+
function stop() {
115+
if (state.timer !== undefined) options.clock.clearTimeout(state.timer)
116+
state.timer = undefined
117+
}
118+
119+
return {
120+
/** Whether the prompt holds a selection made with the mouse. */
121+
selected: () => state.selected && input.hasSelection(),
122+
/** Places the cursor for a pending single click now, before input that depends on its position. */
123+
flush: () => {
124+
if (state.click) place()
125+
},
126+
dispose: stop,
127+
}
128+
}
129+
130+
export type PointerSelection = ReturnType<typeof createPointerSelection>

‎packages/tui/src/util/selection.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type Toast = {
1010
type FocusableSelectionTarget = {
1111
hasSelection: () => boolean
1212
getClipboardText?: (text: string) => string
13+
hasPointerSelection?: () => boolean
1314
}
1415

1516
type Renderer = {
@@ -111,7 +112,8 @@ export function handleSelectionKey(
111112
return
112113
}
113114

114-
if (editing) return
115+
// A mouse selection that left the editor cursor in place is not edited; the key acts at the cursor instead.
116+
if (editing && !focus?.hasPointerSelection?.()) return
115117

116118
renderer.clearSelection()
117119
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { expect, test } from "bun:test"
3+
import type { TextareaRenderable } from "@opentui/core"
4+
import { ManualClock } from "@opentui/core/testing"
5+
import { testRender } from "@opentui/solid"
6+
import { createPointerSelection, MULTI_CLICK_INTERVAL_MS } from "../../src/component/prompt/pointer-selection"
7+
8+
async function fixture(enabled = true) {
9+
const clock = new ManualClock()
10+
const clicks: number[] = []
11+
const refs: { input?: TextareaRenderable } = {}
12+
const app = await testRender(
13+
() => (
14+
<textarea
15+
width={30}
16+
height={3}
17+
focused
18+
initialValue="hello world again"
19+
ref={(input: TextareaRenderable) => {
20+
refs.input = input
21+
}}
22+
/>
23+
),
24+
{ width: 40, height: 5, clock, kittyKeyboard: true },
25+
)
26+
await app.renderOnce()
27+
const input = refs.input!
28+
const pointer = createPointerSelection(input, {
29+
clock,
30+
enabled: () => enabled,
31+
onClick: () => clicks.push(input.cursorOffset),
32+
})
33+
input.cursorOffset = 0
34+
return { app, clock, input, pointer, clicks, [Symbol.dispose]: () => app.renderer.destroy() }
35+
}
36+
37+
test("a mouse drag selects text without moving the cursor", async () => {
38+
using setup = await fixture()
39+
40+
await setup.app.mockMouse.drag(6, 0, 10, 0)
41+
42+
expect(setup.input.getSelectedText()).toBe("world")
43+
expect(setup.pointer.selected()).toBe(true)
44+
expect(setup.input.cursorOffset).toBe(0)
45+
setup.clock.advance(MULTI_CLICK_INTERVAL_MS + 1)
46+
expect(setup.input.cursorOffset).toBe(0)
47+
expect(setup.clicks).toEqual([])
48+
})
49+
50+
test("a single click places the cursor once it can no longer become a double click", async () => {
51+
using setup = await fixture()
52+
53+
await setup.app.mockMouse.click(6, 0)
54+
expect(setup.input.cursorOffset).toBe(0)
55+
setup.clock.advance(MULTI_CLICK_INTERVAL_MS - 1)
56+
expect(setup.input.cursorOffset).toBe(0)
57+
setup.clock.advance(1)
58+
59+
expect(setup.input.cursorOffset).toBe(6)
60+
expect(setup.clicks).toEqual([6])
61+
expect(setup.pointer.selected()).toBe(false)
62+
})
63+
64+
test("a double click selects a word and never places the cursor", async () => {
65+
using setup = await fixture()
66+
67+
await setup.app.mockMouse.click(7, 0)
68+
await setup.app.mockMouse.click(7, 0)
69+
setup.clock.advance(MULTI_CLICK_INTERVAL_MS + 1)
70+
71+
expect(setup.input.getSelectedText()).toBe("world")
72+
expect(setup.pointer.selected()).toBe(true)
73+
expect(setup.input.cursorOffset).toBe(0)
74+
expect(setup.clicks).toEqual([])
75+
})
76+
77+
test("flush places a pending click at once", async () => {
78+
using setup = await fixture()
79+
80+
await setup.app.mockMouse.click(12, 0)
81+
setup.pointer.flush()
82+
83+
expect(setup.input.cursorOffset).toBe(12)
84+
expect(setup.clicks).toEqual([12])
85+
setup.clock.advance(MULTI_CLICK_INTERVAL_MS + 1)
86+
expect(setup.clicks).toEqual([12])
87+
})
88+
89+
test("keyboard selection still moves the cursor", async () => {
90+
using setup = await fixture()
91+
92+
setup.app.mockInput.pressArrow("right", { shift: true })
93+
setup.app.mockInput.pressArrow("right", { shift: true })
94+
95+
expect(setup.input.hasSelection()).toBe(true)
96+
expect(setup.input.cursorOffset).toBe(2)
97+
expect(setup.pointer.selected()).toBe(false)
98+
})
99+
100+
test("the mouse moves the cursor as before when disabled", async () => {
101+
using setup = await fixture(false)
102+
103+
await setup.app.mockMouse.click(6, 0)
104+
expect(setup.input.cursorOffset).toBe(6)
105+
106+
setup.app.renderer.clearSelection()
107+
await setup.app.mockMouse.drag(12, 0, 16, 0)
108+
expect(setup.input.getSelectedText()).toBe("again")
109+
expect(setup.input.cursorOffset).toBe(16)
110+
expect(setup.pointer.selected()).toBe(false)
111+
setup.clock.advance(MULTI_CLICK_INTERVAL_MS + 1)
112+
expect(setup.clicks).toEqual([])
113+
})

0 commit comments

Comments
 (0)