Skip to content

Commit f7f3d82

Browse files
committed
Allow pressing space to expand selected text
1 parent ee4baff commit f7f3d82

2 files changed

Lines changed: 303 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 295 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,255 @@ export function Prompt(props: PromptProps) {
154154
const fileStyleId = syntax().getStyleId("extmark.file")!
155155
const agentStyleId = syntax().getStyleId("extmark.agent")!
156156
const pasteStyleId = syntax().getStyleId("extmark.paste")!
157+
const pasteSelectedStyleId = syntax().getStyleId("extmark.paste.selected")!
157158
let promptPartTypeId: number
158159

160+
// Track which extmark is currently highlighted (cursor is at its boundary)
161+
let highlightedExtmarkId: number | null = null
162+
163+
// Get the extmark at a given position (if cursor is inside or at boundary)
164+
function getExtmarkAt(offset: number) {
165+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
166+
for (const extmark of allExtmarks) {
167+
// Check if offset is within extmark bounds (inclusive)
168+
if (offset >= extmark.start && offset <= extmark.end) {
169+
return extmark
170+
}
171+
}
172+
return null
173+
}
174+
175+
// Get part info for an extmark
176+
function getPartForExtmark(extmarkId: number) {
177+
const partIndex = store.extmarkToPartIndex.get(extmarkId)
178+
if (partIndex === undefined) return null
179+
const part = store.prompt.parts[partIndex]
180+
if (!part) return null
181+
return { part, partIndex }
182+
}
183+
184+
// Check if a part is expandable (pasted text with source)
185+
function isExpandablePart(part: (typeof store.prompt.parts)[0]) {
186+
return part?.type === "text" && part.text && part.source?.text
187+
}
188+
189+
// Update extmark style (highlight/unhighlight)
190+
function setExtmarkHighlight(extmarkId: number, highlighted: boolean) {
191+
const extmark = input.extmarks.get(extmarkId)
192+
if (!extmark) return extmarkId
193+
194+
const partInfo = getPartForExtmark(extmarkId)
195+
if (!partInfo || !isExpandablePart(partInfo.part)) return extmarkId
196+
197+
const newStyleId = highlighted ? pasteSelectedStyleId : pasteStyleId
198+
199+
// Recreate extmark with new style
200+
input.extmarks.delete(extmarkId)
201+
const newId = input.extmarks.create({
202+
start: extmark.start,
203+
end: extmark.end,
204+
virtual: true,
205+
styleId: newStyleId,
206+
typeId: promptPartTypeId,
207+
})
208+
209+
// Update the mapping
210+
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
211+
const newMap = new Map(map)
212+
newMap.delete(extmarkId)
213+
newMap.set(newId, partInfo.partIndex)
214+
return newMap
215+
})
216+
217+
return newId
218+
}
219+
220+
// Update highlighting based on cursor position - only highlight expandable parts at boundary
221+
function updateAttachmentHighlight() {
222+
const cursorOffset = input.cursorOffset
223+
const extmarkAtCursor = getExtmarkAt(cursorOffset)
224+
225+
// Check if we're at the start of an expandable extmark
226+
let shouldHighlight: typeof extmarkAtCursor = null
227+
if (extmarkAtCursor) {
228+
const partInfo = getPartForExtmark(extmarkAtCursor.id)
229+
if (partInfo && isExpandablePart(partInfo.part)) {
230+
// Only highlight if cursor is at the start of the extmark
231+
if (cursorOffset === extmarkAtCursor.start) {
232+
shouldHighlight = extmarkAtCursor
233+
}
234+
}
235+
}
236+
237+
// Update highlighting if changed
238+
if (shouldHighlight && highlightedExtmarkId !== shouldHighlight.id) {
239+
// Unhighlight previous
240+
if (highlightedExtmarkId !== null) {
241+
setExtmarkHighlight(highlightedExtmarkId, false)
242+
}
243+
// Highlight new
244+
highlightedExtmarkId = setExtmarkHighlight(shouldHighlight.id, true)
245+
} else if (!shouldHighlight && highlightedExtmarkId !== null) {
246+
// Unhighlight when no longer at boundary
247+
setExtmarkHighlight(highlightedExtmarkId, false)
248+
highlightedExtmarkId = null
249+
}
250+
}
251+
252+
// Delete an attachment (extmark + part)
253+
function deleteAttachment(extmarkId: number) {
254+
const extmark = input.extmarks.get(extmarkId)
255+
if (!extmark) return false
256+
257+
const partIndex = store.extmarkToPartIndex.get(extmarkId)
258+
259+
// Delete the text in the input (extmark text + trailing space if present)
260+
input.cursorOffset = extmark.start
261+
const startLogical = input.logicalCursor
262+
263+
// Check if there's a trailing space after the extmark
264+
const textAfterExtmark = input.plainText.slice(extmark.end)
265+
const hasTrailingSpace = textAfterExtmark.startsWith(" ")
266+
const deleteEnd = hasTrailingSpace ? extmark.end + 1 : extmark.end
267+
268+
input.cursorOffset = deleteEnd
269+
const endLogical = input.logicalCursor
270+
input.deleteRange(startLogical.row, startLogical.col, endLogical.row, endLogical.col)
271+
272+
// Delete the extmark
273+
input.extmarks.delete(extmarkId)
274+
275+
// Update store
276+
setStore(
277+
produce((draft) => {
278+
draft.extmarkToPartIndex.delete(extmarkId)
279+
if (partIndex !== undefined) {
280+
// Mark as deleted by setting to undefined (preserve indices)
281+
draft.prompt.parts[partIndex] = undefined as any
282+
}
283+
}),
284+
)
285+
286+
// Position cursor at where the attachment was
287+
input.cursorOffset = extmark.start
288+
289+
// Clear highlight tracking if we deleted the highlighted one
290+
if (highlightedExtmarkId === extmarkId) {
291+
highlightedExtmarkId = null
292+
}
293+
294+
return true
295+
}
296+
297+
// Expand a pasted text attachment inline
298+
function expandAttachment(extmarkId: number) {
299+
const extmark = input.extmarks.get(extmarkId)
300+
if (!extmark) return false
301+
302+
const partInfo = getPartForExtmark(extmarkId)
303+
if (!partInfo || !isExpandablePart(partInfo.part)) return false
304+
305+
const part = partInfo.part as {
306+
type: "text"
307+
text: string
308+
source: { text: { start: number; end: number; value: string } }
309+
}
310+
311+
// Delete the virtual text + trailing space
312+
input.cursorOffset = extmark.start
313+
const startLogical = input.logicalCursor
314+
315+
const textAfterExtmark = input.plainText.slice(extmark.end)
316+
const hasTrailingSpace = textAfterExtmark.startsWith(" ")
317+
const deleteEnd = hasTrailingSpace ? extmark.end + 1 : extmark.end
318+
319+
input.cursorOffset = deleteEnd
320+
const endLogical = input.logicalCursor
321+
input.deleteRange(startLogical.row, startLogical.col, endLogical.row, endLogical.col)
322+
323+
// Insert the actual pasted text
324+
input.cursorOffset = extmark.start
325+
input.insertText(part.text + " ")
326+
327+
// Delete the extmark and clear the part's source
328+
input.extmarks.delete(extmarkId)
329+
setStore(
330+
produce((draft) => {
331+
draft.extmarkToPartIndex.delete(extmarkId)
332+
if (draft.prompt.parts[partInfo.partIndex]) {
333+
draft.prompt.parts[partInfo.partIndex] = {
334+
...draft.prompt.parts[partInfo.partIndex],
335+
source: undefined,
336+
}
337+
}
338+
}),
339+
)
340+
341+
// Position cursor at end of inserted text
342+
input.cursorOffset = extmark.start + Bun.stringWidth(part.text) + 1
343+
344+
// Clear highlight tracking
345+
if (highlightedExtmarkId === extmarkId) {
346+
highlightedExtmarkId = null
347+
}
348+
349+
return true
350+
}
351+
352+
// Handle cursor movement - skip over attachments as atomic units
353+
function handleCursorMovement(direction: "left" | "right"): boolean {
354+
const cursorOffset = input.cursorOffset
355+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
356+
357+
if (direction === "right") {
358+
// Check if we're at the start of an extmark - skip to end + 1
359+
for (const extmark of allExtmarks) {
360+
if (cursorOffset === extmark.start) {
361+
// Check if there's a trailing space
362+
const textAfterExtmark = input.plainText.slice(extmark.end)
363+
const hasTrailingSpace = textAfterExtmark.startsWith(" ")
364+
input.cursorOffset = hasTrailingSpace ? extmark.end + 1 : extmark.end
365+
queueMicrotask(() => updateAttachmentHighlight())
366+
return true
367+
}
368+
}
369+
} else if (direction === "left") {
370+
// Check if we're right after an extmark (at end + 1 for trailing space, or at end)
371+
for (const extmark of allExtmarks) {
372+
const textAfterExtmark = input.plainText.slice(extmark.end)
373+
const hasTrailingSpace = textAfterExtmark.startsWith(" ")
374+
const positionAfterExtmark = hasTrailingSpace ? extmark.end + 1 : extmark.end
375+
376+
if (cursorOffset === positionAfterExtmark || cursorOffset === extmark.end) {
377+
input.cursorOffset = extmark.start
378+
queueMicrotask(() => updateAttachmentHighlight())
379+
return true
380+
}
381+
}
382+
}
383+
384+
return false
385+
}
386+
387+
// Handle backspace - delete attachment if at its boundary
388+
function handleBackspace(): boolean {
389+
const cursorOffset = input.cursorOffset
390+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
391+
392+
// Check if we're right after an extmark
393+
for (const extmark of allExtmarks) {
394+
const textAfterExtmark = input.plainText.slice(extmark.end)
395+
const hasTrailingSpace = textAfterExtmark.startsWith(" ")
396+
const positionAfterExtmark = hasTrailingSpace ? extmark.end + 1 : extmark.end
397+
398+
if (cursorOffset === positionAfterExtmark) {
399+
return deleteAttachment(extmark.id)
400+
}
401+
}
402+
403+
return false
404+
}
405+
159406
command.register(() => {
160407
return [
161408
{
@@ -1019,6 +1266,46 @@ export function Prompt(props: PromptProps) {
10191266
}
10201267
}
10211268
if (store.mode === "normal") autocomplete.onKeyDown(e)
1269+
1270+
// Handle atomic attachment navigation and actions
1271+
if (!autocomplete.visible) {
1272+
// Arrow keys - skip over attachments as atomic units
1273+
if (e.name === "right" && !e.shift) {
1274+
if (handleCursorMovement("right")) {
1275+
e.preventDefault()
1276+
return
1277+
}
1278+
}
1279+
if (e.name === "left" && !e.shift) {
1280+
if (handleCursorMovement("left")) {
1281+
e.preventDefault()
1282+
return
1283+
}
1284+
}
1285+
1286+
// Backspace - delete entire attachment if at its boundary
1287+
if (e.name === "backspace") {
1288+
if (handleBackspace()) {
1289+
e.preventDefault()
1290+
return
1291+
}
1292+
}
1293+
1294+
// Space - expand paste attachment if cursor is at its start
1295+
if (e.name === "space") {
1296+
const cursorOffset = input.cursorOffset
1297+
const extmark = getExtmarkAt(cursorOffset)
1298+
if (extmark && cursorOffset === extmark.start) {
1299+
const partInfo = getPartForExtmark(extmark.id)
1300+
if (partInfo && isExpandablePart(partInfo.part)) {
1301+
e.preventDefault()
1302+
expandAttachment(extmark.id)
1303+
return
1304+
}
1305+
}
1306+
}
1307+
}
1308+
10221309
if (!autocomplete.visible) {
10231310
if (
10241311
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
@@ -1042,6 +1329,10 @@ export function Prompt(props: PromptProps) {
10421329
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
10431330
input.cursorOffset = input.plainText.length
10441331
}
1332+
1333+
// Update paste extmark highlighting after cursor movement
1334+
// Use queueMicrotask to run after the default key handler processes movement
1335+
queueMicrotask(() => updateAttachmentHighlight())
10451336
}}
10461337
onSubmit={submit}
10471338
onPaste={async (event: PasteEvent) => {
@@ -1110,7 +1401,10 @@ export function Prompt(props: PromptProps) {
11101401
input.cursorColor = theme.text
11111402
}, 0)
11121403
}}
1113-
onMouseDown={(r: MouseEvent) => r.target?.focus()}
1404+
onMouseDown={(r: MouseEvent) => {
1405+
r.target?.focus()
1406+
queueMicrotask(() => updateAttachmentHighlight())
1407+
}}
11141408
focusedBackgroundColor={theme.backgroundElement}
11151409
cursorColor={theme.text}
11161410
syntaxStyle={syntax()}

packages/opencode/src/cli/cmd/tui/context/theme.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,14 @@ function getSyntaxRules(theme: Theme) {
656656
bold: true,
657657
},
658658
},
659+
{
660+
scope: ["extmark.paste.selected"],
661+
style: {
662+
foreground: theme.background,
663+
background: theme.primary,
664+
bold: true,
665+
},
666+
},
659667
{
660668
scope: ["comment"],
661669
style: {

0 commit comments

Comments
 (0)