Skip to content

Commit e7eb9dd

Browse files
committed
feat(tui): make pasted summaries expandable in prompt
1 parent f847564 commit e7eb9dd

File tree

2 files changed

+113
-19
lines changed

2 files changed

+113
-19
lines changed

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

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,20 @@ export function Prompt(props: PromptProps) {
124124
const [store, setStore] = createStore<{
125125
prompt: PromptInfo
126126
mode: "normal" | "shell"
127-
extmarkToPartIndex: Map<number, number>
127+
partByExtmark: Map<number, number>
128128
interrupt: number
129129
placeholder: number
130+
expanded: Set<number>
130131
}>({
131132
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
132133
prompt: {
133134
input: "",
134135
parts: [],
135136
},
136137
mode: "normal",
137-
extmarkToPartIndex: new Map(),
138+
partByExtmark: new Map(),
138139
interrupt: 0,
140+
expanded: new Set(),
139141
})
140142

141143
createEffect(
@@ -179,6 +181,8 @@ export function Prompt(props: PromptProps) {
179181
onSelect: (dialog) => {
180182
input.extmarks.clear()
181183
input.clear()
184+
setStore("partByExtmark", new Map())
185+
setStore("expanded", new Set())
182186
dialog.clear()
183187
},
184188
},
@@ -381,7 +385,8 @@ export function Prompt(props: PromptProps) {
381385
input: "",
382386
parts: [],
383387
})
384-
setStore("extmarkToPartIndex", new Map())
388+
setStore("partByExtmark", new Map())
389+
setStore("expanded", new Set())
385390
},
386391
submit() {
387392
submit()
@@ -395,7 +400,8 @@ export function Prompt(props: PromptProps) {
395400

396401
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
397402
input.extmarks.clear()
398-
setStore("extmarkToPartIndex", new Map())
403+
setStore("partByExtmark", new Map())
404+
setStore("expanded", new Set())
399405

400406
parts.forEach((part, partIndex) => {
401407
let start = 0
@@ -416,7 +422,7 @@ export function Prompt(props: PromptProps) {
416422
} else if (part.type === "text" && part.source?.text) {
417423
start = part.source.text.start
418424
end = part.source.text.end
419-
virtualText = part.source.text.value
425+
virtualText = part.source.text.value || summary(part.text)
420426
styleId = pasteStyleId
421427
}
422428

@@ -427,8 +433,9 @@ export function Prompt(props: PromptProps) {
427433
virtual: true,
428434
styleId,
429435
typeId: promptPartTypeId,
436+
onClick: part.type === "text" ? (id) => toggle(id) : undefined,
430437
})
431-
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
438+
setStore("partByExtmark", (map: Map<number, number>) => {
432439
const newMap = new Map(map)
433440
newMap.set(extmarkId, partIndex)
434441
return newMap
@@ -445,7 +452,7 @@ export function Prompt(props: PromptProps) {
445452
const newParts: typeof draft.prompt.parts = []
446453

447454
for (const extmark of allExtmarks) {
448-
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
455+
const partIndex = draft.partByExtmark.get(extmark.id)
449456
if (partIndex !== undefined) {
450457
const part = draft.prompt.parts[partIndex]
451458
if (part) {
@@ -458,14 +465,17 @@ export function Prompt(props: PromptProps) {
458465
} else if (part.type === "text" && part.source?.text) {
459466
part.source.text.start = extmark.start
460467
part.source.text.end = extmark.end
468+
if (draft.expanded.has(extmark.id)) {
469+
part.text = read(part, extmark)
470+
}
461471
}
462472
newMap.set(extmark.id, newParts.length)
463473
newParts.push(part)
464474
}
465475
}
466476
}
467477

468-
draft.extmarkToPartIndex = newMap
478+
draft.partByExtmark = newMap
469479
draft.prompt.parts = newParts
470480
}),
471481
)
@@ -486,7 +496,8 @@ export function Prompt(props: PromptProps) {
486496
input.extmarks.clear()
487497
input.clear()
488498
setStore("prompt", { input: "", parts: [] })
489-
setStore("extmarkToPartIndex", new Map())
499+
setStore("partByExtmark", new Map())
500+
setStore("expanded", new Set())
490501
dialog.clear()
491502
},
492503
},
@@ -569,7 +580,7 @@ export function Prompt(props: PromptProps) {
569580
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
570581

571582
for (const extmark of sortedExtmarks) {
572-
const partIndex = store.extmarkToPartIndex.get(extmark.id)
583+
const partIndex = store.partByExtmark.get(extmark.id)
573584
if (partIndex !== undefined) {
574585
const part = store.prompt.parts[partIndex]
575586
if (part?.type === "text" && part.text) {
@@ -660,7 +671,8 @@ export function Prompt(props: PromptProps) {
660671
input: "",
661672
parts: [],
662673
})
663-
setStore("extmarkToPartIndex", new Map())
674+
setStore("partByExtmark", new Map())
675+
setStore("expanded", new Set())
664676
props.onSubmit?.()
665677

666678
// temporary hack to make sure the message is sent
@@ -688,6 +700,7 @@ export function Prompt(props: PromptProps) {
688700
virtual: true,
689701
styleId: pasteStyleId,
690702
typeId: promptPartTypeId,
703+
onClick: (id) => toggle(id),
691704
})
692705

693706
setStore(
@@ -704,11 +717,92 @@ export function Prompt(props: PromptProps) {
704717
},
705718
},
706719
})
707-
draft.extmarkToPartIndex.set(extmarkId, partIndex)
720+
draft.partByExtmark.set(extmarkId, partIndex)
708721
}),
709722
)
710723
}
711724

725+
function lines(text: string) {
726+
return (text.match(/\n/g)?.length ?? 0) + 1
727+
}
728+
729+
function summary(text: string) {
730+
return `[Pasted ~${lines(text)} lines]`
731+
}
732+
733+
function read(part: Extract<PromptInfo["parts"][number], { type: "text" }>, extmark: { start: number; end: number }) {
734+
if (!part.source?.text) return part.text
735+
return input.plainText.slice(extmark.start, extmark.end)
736+
}
737+
738+
function toggle(id: number) {
739+
if (!input || input.isDestroyed) return
740+
741+
const idx = store.partByExtmark.get(id)
742+
if (idx === undefined) return
743+
744+
const part = store.prompt.parts[idx]
745+
if (!part || part.type !== "text" || !part.source?.text) return
746+
747+
const extmark = input.extmarks.get(id)
748+
if (!extmark) return
749+
750+
const start = extmark.start
751+
const end = extmark.end
752+
const viewport = input.editorView.getViewport()
753+
const cursor = input.visualCursor.offset
754+
755+
const open = store.expanded.has(id)
756+
const next = open ? read(part, extmark) : part.text
757+
const tag = summary(next)
758+
const text = open ? tag : next
759+
const cur =
760+
cursor < start
761+
? cursor
762+
: cursor > end
763+
? cursor + text.length - (end - start)
764+
: Math.min(start + text.length, cursor)
765+
const from = input.editBuffer.offsetToPosition(start)
766+
const to = input.editBuffer.offsetToPosition(end + 1)
767+
if (!from || !to) return
768+
769+
input.extmarks.delete(id)
770+
input.editBuffer.deleteRange(from.row, from.col, to.row, to.col)
771+
input.editBuffer.setCursorByOffset(start)
772+
input.editBuffer.insertText(text + " ")
773+
const nextId = input.extmarks.create({
774+
start,
775+
end: start + text.length,
776+
virtual: open,
777+
styleId: pasteStyleId,
778+
typeId: promptPartTypeId,
779+
onClick: (id) => toggle(id),
780+
})
781+
782+
setStore(
783+
produce((draft) => {
784+
draft.partByExtmark.delete(id)
785+
draft.partByExtmark.set(nextId, idx)
786+
const item = draft.prompt.parts[idx]
787+
if (item?.type === "text" && item.source?.text) {
788+
item.text = next
789+
item.source.text.value = tag
790+
}
791+
if (draft.expanded.has(id)) {
792+
draft.expanded.delete(id)
793+
draft.expanded.delete(nextId)
794+
} else {
795+
draft.expanded.add(nextId)
796+
}
797+
}),
798+
)
799+
800+
input.editBuffer.setCursorByOffset(cur)
801+
input.editorView.setViewport(viewport.offsetX, viewport.offsetY, viewport.width, viewport.height, false)
802+
input.getLayoutNode().markDirty()
803+
renderer.requestRender()
804+
}
805+
712806
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
713807
const currentOffset = input.visualCursor.offset
714808
const extmarkStart = currentOffset
@@ -746,7 +840,7 @@ export function Prompt(props: PromptProps) {
746840
produce((draft) => {
747841
const partIndex = draft.prompt.parts.length
748842
draft.prompt.parts.push(part)
749-
draft.extmarkToPartIndex.set(extmarkId, partIndex)
843+
draft.partByExtmark.set(extmarkId, partIndex)
750844
}),
751845
)
752846
return
@@ -805,7 +899,7 @@ export function Prompt(props: PromptProps) {
805899
setStore("prompt", produce(cb))
806900
}}
807901
setExtmark={(partIndex, extmarkId) => {
808-
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
902+
setStore("partByExtmark", (map: Map<number, number>) => {
809903
const newMap = new Map(map)
810904
newMap.set(extmarkId, partIndex)
811905
return newMap
@@ -876,7 +970,8 @@ export function Prompt(props: PromptProps) {
876970
input: "",
877971
parts: [],
878972
})
879-
setStore("extmarkToPartIndex", new Map())
973+
setStore("partByExtmark", new Map())
974+
setStore("expanded", new Set())
880975
return
881976
}
882977
if (keybind.match("app_exit", e)) {
@@ -977,13 +1072,12 @@ export function Prompt(props: PromptProps) {
9771072
} catch {}
9781073
}
9791074

980-
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
9811075
if (
982-
(lineCount >= 3 || pastedContent.length > 150) &&
1076+
(lines(pastedContent) >= 3 || pastedContent.length > 150) &&
9831077
!sync.data.config.experimental?.disable_paste_summary
9841078
) {
9851079
event.preventDefault()
986-
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
1080+
pasteText(pastedContent, summary(pastedContent))
9871081
return
9881082
}
9891083

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function Home() {
114114
<Logo />
115115
</box>
116116
<box height={1} minHeight={0} flexShrink={1} />
117-
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
117+
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} height={10} minHeight={10} flexShrink={0}>
118118
<Prompt
119119
ref={(r) => {
120120
prompt = r

0 commit comments

Comments
 (0)