Skip to content

Commit af0f40e

Browse files
thdxrkommanderactions-user
authored
Opentui textarea (#3399)
Co-authored-by: Sebastian Herrlinger <[email protected]> Co-authored-by: GitHub Action <[email protected]>
1 parent 96498cf commit af0f40e

File tree

6 files changed

+339
-124
lines changed

6 files changed

+339
-124
lines changed

bun.lock

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,8 @@
185185
"@opencode-ai/plugin": "workspace:*",
186186
"@opencode-ai/script": "workspace:*",
187187
"@opencode-ai/sdk": "workspace:*",
188-
"@opentui/core": "0.1.30",
189-
"@opentui/solid": "0.1.30",
188+
"@opentui/core": "0.0.0-20251027-327d7e76",
189+
"@opentui/solid": "0.0.0-20251027-327d7e76",
190190
"@parcel/watcher": "2.5.1",
191191
"@pierre/precision-diffs": "catalog:",
192192
"@solid-primitives/event-bus": "1.1.2",
@@ -954,21 +954,21 @@
954954

955955
"@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
956956

957-
"@opentui/core": ["@opentui/core@0.1.30", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.30", "@opentui/core-darwin-x64": "0.1.30", "@opentui/core-linux-arm64": "0.1.30", "@opentui/core-linux-x64": "0.1.30", "@opentui/core-win32-arm64": "0.1.30", "@opentui/core-win32-x64": "0.1.30", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-DoPF3E//UaISDfp7jYhdU4KbOe7BVm9KqCV+TPMVo2lch8UfvtN2nCnHqtg54DCzxYuTbge9NDrapdt3jrT2oA=="],
957+
"@opentui/core": ["@opentui/core@0.0.0-20251027-327d7e76", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-darwin-x64": "0.0.0-20251027-327d7e76", "@opentui/core-linux-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-linux-x64": "0.0.0-20251027-327d7e76", "@opentui/core-win32-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-win32-x64": "0.0.0-20251027-327d7e76", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-9EF4TkLR4szqNmWDGYZrzti48aQ3WOaXbTKOxcAEIBNienTlvr7baNyUjwNCHsbMxsQrAIYIY7gKXjebsChxkg=="],
958958

959-
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.30", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFFBsVsrByzA/9LpJZ7uV2QWH44Hq/96eWO8PlPhMdlnvpqz5e0PYU3fQJUuDGptIvb9GZzIt5Og2gYar5bUIw=="],
959+
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251027-327d7e76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iOY4266FFXc1c2NqYBg5ED1YkfT5z7yVCrlLsqd9EIpWv72NIN3b9HLbY77jzsjDYqtFfOx2x96FyS8As+La3w=="],
960960

961-
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.30", "", { "os": "darwin", "cpu": "x64" }, "sha512-f8MNPqwfG9qUttxcrQ3VsM+rrWvO9uHGncTJbKYEKDVaQzBuKpkEZqMSA2JV3gspjL22DXSG4Q5LE6NzvmITgQ=="],
961+
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251027-327d7e76", "", { "os": "darwin", "cpu": "x64" }, "sha512-fM441iHIG9TRRBv9y/bNeG4ZymhKi4FTtdvaPDRJ9rqljTGL/NwiP57kMiaO8Gv2y9dqzUmxDyu0+QHqexl6BQ=="],
962962

963-
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.30", "", { "os": "linux", "cpu": "arm64" }, "sha512-6qBVjUU9XJrgePvFtCGmEIU+iRfyyydvbts+i7nKjJgmOFVZLnllP6XqA3s+lAjEQrzp+VM7beDV5MIOMjHHZg=="],
963+
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251027-327d7e76", "", { "os": "linux", "cpu": "arm64" }, "sha512-fDnx9X6+UcPj5p3LnJV2RAKwEw82pabZwc6eIPsUNIWZj861/OnDOmxwDEMLJw4G+j5IBnk5oeHoNu6YeG0V1Q=="],
964964

965-
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.30", "", { "os": "linux", "cpu": "x64" }, "sha512-K51WGZp7VT5aB8nbzZBt94YavMnI1yFe4h4n/e4TxZ4Ph3BACNmNHizEDfn05efcmXmMdgGOjazc5sNA2LrOOQ=="],
965+
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251027-327d7e76", "", { "os": "linux", "cpu": "x64" }, "sha512-oUNT2QuL+b3e7g22KsUGWsi1lktiorLg1xlgEB+HWpn7dtK9xX+6gc1dmXp2qtgJvXhlOWSCCBftSmVvLFn4eg=="],
966966

967-
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.30", "", { "os": "win32", "cpu": "arm64" }, "sha512-NAkCu0VSHh5oj9wldjImqli7g4kvkA/3HP9PyAWB6zBg+QcXCl7Yi1WPEFwAU7XM1zEUBwhrj0sHcuMomq4Y8A=="],
967+
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251027-327d7e76", "", { "os": "win32", "cpu": "arm64" }, "sha512-xKFfTRO/JYTnQD+Erd9tOaEPvJPwtOVDQNQ5VSRPXdyB2YAruiZ57nnryJJ6WQjIXPy+ND4X77mylxOUFTk5Wg=="],
968968

969-
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.30", "", { "os": "win32", "cpu": "x64" }, "sha512-eNIQaLm+Muluma1xXa2SxHGidjknH+iVmPPbQsV7xwJlp3a318BjTr/5/lxKBz1EGzhTaiwBqwcgTSg+Q31L5w=="],
969+
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251027-327d7e76", "", { "os": "win32", "cpu": "x64" }, "sha512-Ymctt059k8ZVXnLCcqZC+h1f81W0KgKPGzS3Ve/+Br/v7fwsE0mWCNuinbxrFgIHMs+6947u88rp/tyhwMQBJQ=="],
970970

971-
"@opentui/solid": ["@opentui/solid@0.1.30", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.30", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-7JAHQH8OLgJCjH2IBmdO3ggd7WHavEdlywhVzjoVvQYonTYcJjcJ2F6MJ1Qmqqm6y2+IYv3KIYMQm6HLxX5TxQ=="],
971+
"@opentui/solid": ["@opentui/solid@0.0.0-20251027-327d7e76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251027-327d7e76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-/eTp1WbjRQs2tVqXPgAVKlhMmzJP60SZ+ZJ8S4cqdQ12g++WrFWCMb8OcueLs/rgKHr07WORo7Aw9/a9I1vV/w=="],
972972

973973
"@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
974974

packages/opencode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@
5050
"@opencode-ai/plugin": "workspace:*",
5151
"@opencode-ai/script": "workspace:*",
5252
"@opencode-ai/sdk": "workspace:*",
53-
"@opentui/core": "0.1.30",
54-
"@opentui/solid": "0.1.30",
53+
"@opentui/core": "0.0.0-20251027-327d7e76",
54+
"@opentui/solid": "0.0.0-20251027-327d7e76",
5555
"@parcel/watcher": "2.5.1",
5656
"@solid-primitives/event-bus": "1.1.2",
5757
"@pierre/precision-diffs": "catalog:",

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

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ParsedKey, BoxRenderable, InputRenderable } from "@opentui/core"
1+
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
22
import fuzzysort from "fuzzysort"
33
import { firstBy } from "remeda"
44
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
@@ -12,7 +12,7 @@ import type { PromptInfo } from "./history"
1212

1313
export type AutocompleteRef = {
1414
onInput: (value: string) => void
15-
onKeyDown: (e: ParsedKey) => void
15+
onKeyDown: (e: KeyEvent) => void
1616
visible: false | "@" | "/"
1717
}
1818

@@ -27,9 +27,13 @@ export function Autocomplete(props: {
2727
value: string
2828
sessionID?: string
2929
setPrompt: (input: (prompt: PromptInfo) => void) => void
30+
setExtmark: (partIndex: number, extmarkId: number) => void
3031
anchor: () => BoxRenderable
31-
input: () => InputRenderable
32+
input: () => TextareaRenderable
3233
ref: (ref: AutocompleteRef) => void
34+
fileStyleId: number
35+
agentStyleId: number
36+
promptPartTypeId: () => number
3337
}) {
3438
const sdk = useSDK()
3539
const sync = useSync()
@@ -46,6 +50,49 @@ export function Autocomplete(props: {
4650
return props.value.substring(store.index + 1).split(" ")[0]
4751
})
4852

53+
function insertPart(text: string, part: PromptInfo["parts"][number]) {
54+
const append = "@" + text + " "
55+
const input = props.input()
56+
const currentCursorOffset = input.visualCursor.offset
57+
58+
input.cursorOffset = store.index
59+
const startCursor = input.logicalCursor
60+
input.cursorOffset = currentCursorOffset
61+
const endCursor = input.logicalCursor
62+
63+
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
64+
input.insertText(append)
65+
66+
const virtualText = "@" + text
67+
const extmarkStart = store.index
68+
const extmarkEnd = extmarkStart + virtualText.length
69+
70+
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
71+
72+
const extmarkId = input.extmarks.create({
73+
start: extmarkStart,
74+
end: extmarkEnd,
75+
virtual: true,
76+
styleId,
77+
typeId: props.promptPartTypeId(),
78+
})
79+
80+
props.setPrompt((draft) => {
81+
if (part.type === "file" && part.source?.text) {
82+
part.source.text.start = extmarkStart
83+
part.source.text.end = extmarkEnd
84+
part.source.text.value = virtualText
85+
} else if (part.type === "agent" && part.source) {
86+
part.source.start = extmarkStart
87+
part.source.end = extmarkEnd
88+
part.source.value = virtualText
89+
}
90+
const partIndex = draft.parts.length
91+
draft.parts.push(part)
92+
props.setExtmark(partIndex, extmarkId)
93+
})
94+
}
95+
4996
const [files] = createResource(
5097
() => [filter()],
5198
async () => {
@@ -68,26 +115,20 @@ export function Autocomplete(props: {
68115
(item): AutocompleteOption => ({
69116
display: item,
70117
onSelect: () => {
71-
const part: PromptInfo["parts"][number] = {
118+
insertPart(item, {
72119
type: "file",
73120
mime: "text/plain",
74121
filename: item,
75122
url: `file://${process.cwd()}/${item}`,
76123
source: {
77124
type: "file",
78125
text: {
79-
start: store.index,
80-
end: store.index + item.length + 1,
81-
value: "@" + item,
126+
start: 0,
127+
end: 0,
128+
value: "",
82129
},
83130
path: item,
84131
},
85-
}
86-
props.setPrompt((draft) => {
87-
const append = "@" + item + " "
88-
if (store.index === 0) draft.input = append
89-
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
90-
draft.parts.push(part)
91132
})
92133
},
93134
}),
@@ -111,18 +152,14 @@ export function Autocomplete(props: {
111152
(agent): AutocompleteOption => ({
112153
display: "@" + agent.name,
113154
onSelect: () => {
114-
props.setPrompt((draft) => {
115-
const append = "@" + agent.name + " "
116-
draft.input = append
117-
draft.parts.push({
118-
type: "agent",
119-
source: {
120-
start: store.index,
121-
end: store.index + agent.name.length + 1,
122-
value: "@" + agent.name,
123-
},
124-
name: agent.name,
125-
})
155+
insertPart(agent.name, {
156+
type: "agent",
157+
name: agent.name,
158+
source: {
159+
start: 0,
160+
end: 0,
161+
value: "",
162+
},
126163
})
127164
},
128165
}),
@@ -138,8 +175,11 @@ export function Autocomplete(props: {
138175
display: "/" + command.name,
139176
description: command.description,
140177
onSelect: () => {
141-
props.input().value = "/" + command.name + " "
142-
props.input().cursorPosition = props.input().value.length
178+
const newText = "/" + command.name + " "
179+
const cursor = props.input().logicalCursor
180+
props.input().deleteRange(0, 0, cursor.row, cursor.col)
181+
props.input().insertText(newText)
182+
props.input().cursorOffset = Bun.stringWidth(newText)
143183
},
144184
})
145185
}
@@ -234,13 +274,13 @@ export function Autocomplete(props: {
234274
const selected = options()[store.selected]
235275
if (!selected) return
236276
selected.onSelect?.()
237-
setTimeout(() => hide(), 0)
277+
hide()
238278
}
239279

240280
function show(mode: "@" | "/") {
241281
setStore({
242282
visible: mode,
243-
index: props.input().cursorPosition,
283+
index: props.input().visualCursor.offset,
244284
position: {
245285
x: props.anchor().x,
246286
y: props.anchor().y,
@@ -250,7 +290,10 @@ export function Autocomplete(props: {
250290
}
251291

252292
function hide() {
253-
if (store.visible === "/" && !props.value.endsWith(" ")) props.input().value = ""
293+
if (store.visible === "/" && !props.value.endsWith(" ")) {
294+
const cursor = props.input().logicalCursor
295+
props.input().deleteRange(0, 0, cursor.row, cursor.col)
296+
}
254297
setStore("visible", false)
255298
}
256299

@@ -262,12 +305,13 @@ export function Autocomplete(props: {
262305
onInput(value: string) {
263306
if (store.visible && value.length <= store.index) hide()
264307
},
265-
onKeyDown(e: ParsedKey) {
308+
onKeyDown(e: KeyEvent) {
266309
if (store.visible) {
267310
if (e.name === "up") move(-1)
268311
if (e.name === "down") move(1)
269312
if (e.name === "escape") hide()
270313
if (e.name === "return") select()
314+
if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
271315
}
272316
if (!store.visible) {
273317
if (e.name === "@") {
@@ -278,7 +322,7 @@ export function Autocomplete(props: {
278322
}
279323

280324
if (e.name === "/") {
281-
if (props.input().cursorPosition === 0) show("/")
325+
if (props.input().visualCursor.offset === 0) show("/")
282326
}
283327
}
284328
},

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@ import { createStore, produce } from "solid-js/store"
55
import { clone } from "remeda"
66
import { createSimpleContext } from "../../context/helper"
77
import { appendFile } from "fs/promises"
8-
import type { AgentPart, FilePart } from "@opencode-ai/sdk"
8+
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
99

1010
export type PromptInfo = {
1111
input: string
12-
parts: (Omit<FilePart, "id" | "messageID" | "sessionID"> | Omit<AgentPart, "id" | "messageID" | "sessionID">)[]
12+
parts: (
13+
| Omit<FilePart, "id" | "messageID" | "sessionID">
14+
| Omit<AgentPart, "id" | "messageID" | "sessionID">
15+
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
16+
source?: {
17+
text: {
18+
start: number
19+
end: number
20+
value: string
21+
}
22+
}
23+
})
24+
)[]
1325
}
1426

1527
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({

0 commit comments

Comments
 (0)