opencode-vim 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+ import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke } from "@vimee/core"
3
+ import type { CursorPosition, KeybindDefinition, KeybindMap, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
4
+ import type { PromptContext } from "../../prompt/types"
5
+ import { focusedInput, setInput, type EditBufferLike } from "./actions"
6
+ import type { VimConfig } from "./config"
7
+ import type { VimLog } from "./log"
8
+ import { createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
9
+ import type { createVimState } from "./state"
10
+
11
+ type VimState = ReturnType<typeof createVimState>
12
+ type HostAction = VimeeAction | { type: "submit" }
13
+
14
+ export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
15
+
16
+ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog) {
17
+ let buffer = new TextBuffer("")
18
+ let activeMap = createPromptMap("")
19
+ const maps = new Map([[activeMap.vimText, activeMap]])
20
+ let vim = createInitialContext({ line: 0, col: 0 })
21
+ const keybinds = createKeybinds(config, log)
22
+ let timer: ReturnType<typeof setTimeout> | undefined
23
+ let pendingInsert = ""
24
+ let nativeInsertUndoSaved = false
25
+
26
+ return {
27
+ handle(event: KeyEvent, key: string, ctx: PromptContext) {
28
+ const ref = ctx.prompt()
29
+ if (!ref) return false
30
+
31
+ const input = focusedInput(ctx)
32
+ const text = input?.plainText ?? ref.current.input
33
+ const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
34
+ const map = mapForHostText(text, input)
35
+ const cursor = hostPosition(map, offset)
36
+ const vimeeKey = keyForVimee(event, key)
37
+ if (!vimeeKey) return false
38
+
39
+ if (state.mode() === "normal" && key === "<CR>") {
40
+ ref.submit()
41
+ return true
42
+ }
43
+
44
+ if (state.mode() === "insert" && key === "<CR>") {
45
+ cancelPendingInsert(ctx, offset)
46
+ return false
47
+ }
48
+
49
+ if (state.mode() === "insert") {
50
+ const consumed = handleInsertMode(event, vimeeKey, ctx, map, cursor, offset)
51
+ return consumed ?? false
52
+ }
53
+
54
+ const wasPending = keybinds?.isPending() ?? false
55
+ const pendingBefore = pendingInsert
56
+ sync(map, cursor)
57
+
58
+ if (vimeeKey === "A" && appendVisualLine(input, map)) {
59
+ state.setPending("")
60
+ updateTimeout(ctx)
61
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
62
+ return true
63
+ }
64
+
65
+ const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
66
+ vim = result.newCtx
67
+ applyActions(result.actions as HostAction[], ctx, map)
68
+ syncMode(state, vim.mode)
69
+ const keybindPending = keybinds?.isPending() ?? false
70
+ if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
71
+ pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
72
+ state.setPending(pendingDisplay(vim, keybindPending))
73
+ updateTimeout(ctx)
74
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
75
+ return consumesKey(vimeeKey, result.actions, vim, keybindPending)
76
+ },
77
+ cleanup() {
78
+ if (timer) clearTimeout(timer)
79
+ },
80
+ }
81
+
82
+ function sync(map: PromptMap, cursor: CursorPosition) {
83
+ if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
84
+ vim = { ...vim, cursor, mode: state.mode() }
85
+ }
86
+
87
+ function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
88
+ if (activeMap.hostText === text) return activeMap
89
+ activeMap = createPromptMap(text, input)
90
+ rememberMap(activeMap)
91
+ if (state.mode() === "insert") {
92
+ if (!nativeInsertUndoSaved) {
93
+ buffer.saveUndoPoint(vim.cursor)
94
+ nativeInsertUndoSaved = true
95
+ }
96
+ buffer.replaceContent(activeMap.vimText)
97
+ } else {
98
+ buffer = new TextBuffer(activeMap.vimText)
99
+ nativeInsertUndoSaved = false
100
+ }
101
+ return activeMap
102
+ }
103
+
104
+ function rememberMap(map: PromptMap) {
105
+ maps.set(map.vimText, map)
106
+ }
107
+
108
+ function nextMap(map: PromptMap, vimText: string) {
109
+ const known = maps.get(vimText)
110
+ if (known) return known
111
+ const next = derivePromptMap(map, vimText)
112
+ rememberMap(next)
113
+ return next
114
+ }
115
+
116
+ function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
117
+ const ref = ctx.prompt()
118
+ const input = focusedInput(ctx)
119
+ let currentMap = map
120
+ if (!ref) return
121
+
122
+ for (const action of actions) {
123
+ switch (action.type) {
124
+ case "content-change":
125
+ rememberMap(currentMap)
126
+ currentMap = nextMap(currentMap, action.content)
127
+ activeMap = currentMap
128
+ setInput(ref, currentMap.hostText)
129
+ buffer.replaceContent(action.content)
130
+ break
131
+ case "cursor-move":
132
+ setCursor(input, currentMap, action.position)
133
+ break
134
+ case "mode-change":
135
+ nativeInsertUndoSaved = false
136
+ syncMode(state, action.mode)
137
+ break
138
+ case "quit":
139
+ ref.blur()
140
+ break
141
+ case "submit":
142
+ ref.submit()
143
+ break
144
+ }
145
+ }
146
+
147
+ setCursor(input, currentMap, vim.cursor)
148
+ }
149
+
150
+ function handleInsertMode(event: KeyEvent, key: string, ctx: PromptContext, map: PromptMap, cursor: CursorPosition, offset: number) {
151
+ if (key === "Escape") {
152
+ cancelPendingInsert(ctx, offset)
153
+ sync(map, cursor)
154
+ const result = processKeystroke(key, vim, buffer, event.ctrl, false)
155
+ vim = result.newCtx
156
+ applyActions(result.actions as HostAction[], ctx, map)
157
+ syncMode(state, vim.mode)
158
+ state.setPending("")
159
+ log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
160
+ return true
161
+ }
162
+
163
+ if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return undefined
164
+
165
+ const wasPending = keybinds.isPending()
166
+ const pendingBefore = pendingInsert
167
+ const resolved = keybinds.resolve(key, "insert", event.ctrl)
168
+
169
+ switch (resolved.status) {
170
+ case "pending":
171
+ pendingInsert = plainPending(resolved.display)
172
+ state.setPending(resolved.display)
173
+ updateTimeout(ctx)
174
+ return true
175
+ case "matched": {
176
+ sync(map, cursor)
177
+ const actions = applyKeybind(resolved.definition, map)
178
+ applyActions(actions, ctx, map)
179
+ syncMode(state, vim.mode)
180
+ pendingInsert = ""
181
+ state.setPending("")
182
+ updateTimeout(ctx)
183
+ log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
184
+ return true
185
+ }
186
+ case "none":
187
+ if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore, offset)
188
+ pendingInsert = ""
189
+ state.setPending("")
190
+ updateTimeout(ctx)
191
+ return wasPending ? false : undefined
192
+ }
193
+ }
194
+
195
+ function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
196
+ if ("execute" in definition) {
197
+ const actions = definition.execute(vim, buffer) as HostAction[]
198
+ vim = { ...vim, cursor: hostPosition(map, cursorOffset(map, vim.cursor)) }
199
+ return actions
200
+ }
201
+
202
+ let actions: HostAction[] = []
203
+ for (const token of parseKeySequence(definition.keys)) {
204
+ const result = processKeystroke(keyToken(token), vim, buffer, tokenCtrl(token), false)
205
+ vim = result.newCtx
206
+ actions = [...actions, ...(result.actions as HostAction[])]
207
+ }
208
+ return actions
209
+ }
210
+
211
+ function updateTimeout(ctx: PromptContext) {
212
+ if (timer) clearTimeout(timer)
213
+ timer = undefined
214
+ if (!keybinds?.isPending()) return
215
+ timer = setTimeout(() => {
216
+ keybinds.cancel()
217
+ flushPendingInsert(ctx, pendingInsert)
218
+ pendingInsert = ""
219
+ state.setPending("")
220
+ ctx.requestRender()
221
+ }, config.keymapTimeout)
222
+ }
223
+
224
+ function cancelPendingInsert(ctx: PromptContext, offset?: number) {
225
+ if (!keybinds?.isPending()) return
226
+ if (timer) clearTimeout(timer)
227
+ timer = undefined
228
+ keybinds.cancel()
229
+ flushPendingInsert(ctx, pendingInsert, offset)
230
+ pendingInsert = ""
231
+ state.setPending("")
232
+ }
233
+
234
+ function flushPendingInsert(ctx: PromptContext, value: string, offset?: number) {
235
+ if (!value || state.mode() !== "insert") return
236
+ const ref = ctx.prompt()
237
+ if (!ref) return
238
+ const input = focusedInput(ctx)
239
+ const text = input?.plainText ?? ref.current.input
240
+ const insertAt = clamp(offset ?? input?.cursorOffset ?? text.length, 0, text.length)
241
+ const currentOffset = input?.cursorOffset ?? insertAt
242
+ const next = text.slice(0, insertAt) + value + text.slice(insertAt)
243
+ setInput(ref, next)
244
+ activeMap = createPromptMap(next, input)
245
+ rememberMap(activeMap)
246
+ buffer.replaceContent(activeMap.vimText)
247
+ const nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
248
+ if (input) input.cursorOffset = nextOffset
249
+ vim = { ...vim, cursor: hostPosition(activeMap, nextOffset) }
250
+ }
251
+
252
+ function cursorOffset(map: PromptMap, position: CursorPosition) {
253
+ return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
254
+ }
255
+
256
+ function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
257
+ if (!input) return
258
+ input.cursorOffset = cursorOffset(map, position)
259
+ if (vim.mode !== "insert") clampNormalCursor(input)
260
+ }
261
+
262
+ function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
263
+ if (!input?.gotoVisualLineEnd) return false
264
+ input.gotoVisualLineEnd()
265
+ vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
266
+ nativeInsertUndoSaved = false
267
+ syncMode(state, "insert")
268
+ return true
269
+ }
270
+ }
271
+
272
+ function clampNormalCursor(input: EditBufferLike) {
273
+ const cursor = input.visualCursor
274
+ const eol = input.editorView?.getVisualEOL?.()
275
+ const offset = input.cursorOffset
276
+ if (!cursor || !eol || offset === undefined) return
277
+ if (cursor.visualCol === 0) return
278
+ if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
279
+ }
280
+
281
+ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
282
+ const map = createKeybindMap()
283
+ let count = 0
284
+
285
+ for (const [mode, keymaps] of Object.entries(config.keymaps) as Array<[VimeeMode, Record<string, string> | undefined]>) {
286
+ if (!keymaps) continue
287
+ for (const [keys, action] of Object.entries(keymaps)) {
288
+ try {
289
+ map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
290
+ count++
291
+ } catch (error) {
292
+ log("vimee.keymap.invalid", { mode, keys, action, error: error instanceof Error ? error.message : String(error) })
293
+ }
294
+ }
295
+ }
296
+
297
+ return count > 0 ? map : undefined
298
+ }
299
+
300
+ function keybindAction(action: string): KeybindDefinition {
301
+ switch (action) {
302
+ case "normal":
303
+ return { keys: "<Esc>" }
304
+ case "insert":
305
+ return { keys: "i" }
306
+ case "submit":
307
+ return { execute: () => [{ type: "submit" } as unknown as VimeeAction] }
308
+ default:
309
+ return { keys: action }
310
+ }
311
+ }
312
+
313
+ function keyForVimee(event: KeyEvent, key: string) {
314
+ if (event.ctrl) return event.name?.toLowerCase()
315
+ const token = keyToken(key)
316
+ return token.startsWith("<") ? undefined : token
317
+ }
318
+
319
+ function keyToken(token: string) {
320
+ if (token === "<Esc>" || token === "<C-[>") return "Escape"
321
+ if (token === "<CR>") return "Enter"
322
+ if (token === "<Tab>") return "Tab"
323
+ if (token === "<BS>") return "Backspace"
324
+ if (token === "<Del>") return "Delete"
325
+ if (token === "<Space>") return " "
326
+ if (token === "<Up>") return "ArrowUp"
327
+ if (token === "<Down>") return "ArrowDown"
328
+ if (token === "<Left>") return "ArrowLeft"
329
+ if (token === "<Right>") return "ArrowRight"
330
+ if (token === "<Home>") return "Home"
331
+ if (token === "<End>") return "End"
332
+ if (token.startsWith("<C-") && token.endsWith(">")) return token.slice(3, -1).toLowerCase()
333
+ return token
334
+ }
335
+
336
+ function tokenCtrl(token: string) {
337
+ return token.startsWith("<C-") && token !== "<C-[>"
338
+ }
339
+
340
+ function syncMode(state: VimState, mode: VimContext["mode"]) {
341
+ state.setMode(mode === "insert" ? "insert" : "normal")
342
+ }
343
+
344
+ function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
345
+ if (ctx.phase === "operator-pending") return ctx.operator ?? ""
346
+ if (ctx.phase === "text-object-pending") return ctx.textObjectModifier ?? ""
347
+ if (keybindPending) return ctx.statusMessage
348
+ return ""
349
+ }
350
+
351
+ function plainPending(value: string) {
352
+ return value.includes("<") || value.includes(">") ? "" : value
353
+ }
354
+
355
+ function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybindPending: boolean) {
356
+ if (actions.length > 0) return true
357
+ if (keybindPending) return true
358
+ if (ctx.phase !== "idle") return true
359
+ return ctx.mode !== "insert" || key === "Escape"
360
+ }
361
+
362
+ function clamp(value: number, min: number, max: number) {
363
+ return Math.max(min, Math.min(max, value))
364
+ }
package/src/plugin.tsx ADDED
@@ -0,0 +1,69 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { PromptRoot } from "./prompt/root"
4
+ import type { PromptModule } from "./prompt/types"
5
+ import { createVimModule } from "./modules/vim"
6
+ import { createSnippetsModule } from "./modules/snippets"
7
+
8
+ const tui: TuiPlugin = async (api, options) => {
9
+ const moduleCache = new Map<string, PromptModule[]>()
10
+ const createModules = (key: string) => {
11
+ const cached = moduleCache.get(key)
12
+ if (cached) return cached
13
+
14
+ const modules: PromptModule[] = []
15
+ if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule())
16
+ modules.push(createVimModule(options))
17
+ moduleCache.set(key, modules)
18
+ return modules
19
+ }
20
+
21
+ api.slots.register({
22
+ order: 50,
23
+ slots: {
24
+ home_prompt(ctx, props) {
25
+ return <PromptRoot api={api} slot={ctx} kind="home" workspaceID={props.workspace_id} ref={props.ref} modules={createModules(`home:${props.workspace_id ?? ""}`)} />
26
+ },
27
+ session_prompt(ctx, props) {
28
+ return (
29
+ <PromptRoot
30
+ api={api}
31
+ slot={ctx}
32
+ kind="session"
33
+ sessionID={props.session_id}
34
+ visible={props.visible}
35
+ disabled={props.disabled}
36
+ onSubmit={props.on_submit}
37
+ ref={props.ref}
38
+ modules={createModules(`session:${props.session_id}`)}
39
+ />
40
+ )
41
+ },
42
+ },
43
+ })
44
+ }
45
+
46
+ function hasSnippetsPlugin(api: TuiPluginApi) {
47
+ return configHasSnippets(api.state.config.plugin) || configHasSnippets(api.tuiConfig.plugin) || api.plugins.list().some(pluginHasSnippets)
48
+ }
49
+
50
+ function configHasSnippets(plugins: unknown) {
51
+ if (!Array.isArray(plugins)) return false
52
+ return plugins.some((plugin) => specHasSnippets(Array.isArray(plugin) ? plugin[0] : plugin))
53
+ }
54
+
55
+ function pluginHasSnippets(plugin: ReturnType<TuiPluginApi["plugins"]["list"]>[number]) {
56
+ return plugin.enabled && (specHasSnippets(plugin.id) || specHasSnippets(plugin.spec))
57
+ }
58
+
59
+ function specHasSnippets(spec: unknown) {
60
+ if (typeof spec !== "string") return false
61
+ return spec === "opencode-snippets" || spec.startsWith("opencode-snippets@") || spec.includes("/opencode-snippets")
62
+ }
63
+
64
+ const plugin: TuiPluginModule & { id: string } = {
65
+ id: "local.opencode-vim",
66
+ tui,
67
+ }
68
+
69
+ export default plugin
@@ -0,0 +1,38 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPromptRef } from "@opencode-ai/plugin/tui"
3
+ import type { JSX } from "@opentui/solid"
4
+ import type { PromptContext, PromptModule } from "./types"
5
+ import { submitModules } from "./modules"
6
+
7
+ type HostPromptProps = {
8
+ ctx: PromptContext
9
+ modules: PromptModule[]
10
+ ref?: (ref: TuiPromptRef | undefined) => void
11
+ right?: JSX.Element
12
+ }
13
+
14
+ export function HostPrompt(props: HostPromptProps) {
15
+ const api = props.ctx.api
16
+
17
+ const setRef = (ref: TuiPromptRef | undefined) => {
18
+ props.ctx.setPromptRef(ref)
19
+ props.ref?.(ref)
20
+ }
21
+
22
+ const onSubmit = () => {
23
+ if (submitModules(props.modules, props.ctx)) return
24
+ props.ctx.submitHost()
25
+ }
26
+
27
+ return (
28
+ <api.ui.Prompt
29
+ sessionID={props.ctx.sessionID}
30
+ workspaceID={props.ctx.workspaceID}
31
+ visible={props.ctx.visible}
32
+ disabled={props.ctx.disabled}
33
+ onSubmit={onSubmit}
34
+ ref={setRef}
35
+ right={props.right}
36
+ />
37
+ )
38
+ }
@@ -0,0 +1,16 @@
1
+ import type { PromptContext, PromptModule } from "./types"
2
+
3
+ export function sortModules(modules: PromptModule[]) {
4
+ return [...modules].sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
5
+ }
6
+
7
+ export function submitModules(modules: PromptModule[], ctx: PromptContext) {
8
+ for (const module of modules) {
9
+ if (module.onSubmit?.(ctx) === true) return true
10
+ }
11
+ return false
12
+ }
13
+
14
+ export function notifyPromptRef(modules: PromptModule[], ref: ReturnType<PromptContext["prompt"]>, ctx: PromptContext) {
15
+ for (const module of modules) module.onPromptRef?.(ref, ctx)
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { TuiPromptRef } from "@opencode-ai/plugin/tui"
2
+ import type { PromptContext, PromptModule } from "./types"
3
+ import { notifyPromptRef } from "./modules"
4
+
5
+ export function createPromptRef(modules: PromptModule[], ctx: Omit<PromptContext, "prompt" | "setPromptRef">) {
6
+ let current: TuiPromptRef | undefined
7
+ const context = {
8
+ ...ctx,
9
+ prompt: () => current,
10
+ setPromptRef(ref: TuiPromptRef | undefined) {
11
+ current = ref
12
+ notifyPromptRef(modules, ref, context)
13
+ },
14
+ } satisfies PromptContext
15
+
16
+ return context
17
+ }
@@ -0,0 +1,56 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { onCleanup } from "solid-js"
3
+ import type { JSX } from "@opentui/solid"
4
+ import { HostPrompt } from "./host"
5
+ import { sortModules } from "./modules"
6
+ import { createPromptRef } from "./ref"
7
+ import type { PromptRootProps } from "./types"
8
+
9
+ export function PromptRoot(props: PromptRootProps) {
10
+ const modules = sortModules(props.modules)
11
+ const api = props.api
12
+ const ctx = createPromptRef(modules, {
13
+ api,
14
+ slot: props.slot,
15
+ kind: props.kind,
16
+ sessionID: props.sessionID,
17
+ workspaceID: props.workspaceID,
18
+ visible: props.visible,
19
+ disabled: props.disabled,
20
+ submitHost: () => props.onSubmit?.(),
21
+ requestRender: () => api.renderer.requestRender(),
22
+ })
23
+
24
+ const cleanups = modules.flatMap((module) => {
25
+ const cleanup = module.setup?.(ctx)
26
+ return typeof cleanup === "function" ? [cleanup] : []
27
+ })
28
+ onCleanup(() => {
29
+ for (const cleanup of cleanups) cleanup()
30
+ })
31
+
32
+ const moduleAbove = modules.map((module) => module.renderAbove?.(ctx)).filter(Boolean) as JSX.Element[]
33
+ const moduleBelow = modules.map((module) => module.renderBelow?.(ctx)).filter(Boolean) as JSX.Element[]
34
+ const moduleRight = modules.map((module) => module.renderRight?.(ctx)).filter(Boolean) as JSX.Element[]
35
+ const hostRight = renderHostRight(props)
36
+
37
+ return (
38
+ <box>
39
+ {moduleAbove}
40
+ <HostPrompt ctx={ctx} modules={modules} ref={props.ref} right={<RightItems items={[...moduleRight, hostRight]} />} />
41
+ {moduleBelow}
42
+ </box>
43
+ )
44
+ }
45
+
46
+ function renderHostRight(props: PromptRootProps) {
47
+ const Slot = props.api.ui.Slot
48
+ if (props.kind === "home") return <Slot name="home_prompt_right" workspace_id={props.workspaceID} />
49
+ return <Slot name="session_prompt_right" session_id={props.sessionID ?? ""} />
50
+ }
51
+
52
+ function RightItems(props: { items: JSX.Element[] }) {
53
+ const items = props.items.filter(Boolean)
54
+ if (items.length === 0) return undefined
55
+ return <box flexDirection="row">{items}</box>
56
+ }
@@ -0,0 +1,42 @@
1
+ import type { JSX } from "@opentui/solid"
2
+ import type { TuiPluginApi, TuiPromptRef, TuiSlotContext } from "@opencode-ai/plugin/tui"
3
+
4
+ export type PromptKind = "home" | "session"
5
+
6
+ export type PromptContext = {
7
+ api: TuiPluginApi
8
+ slot: TuiSlotContext
9
+ kind: PromptKind
10
+ sessionID?: string
11
+ workspaceID?: string
12
+ visible?: boolean
13
+ disabled?: boolean
14
+ prompt: () => TuiPromptRef | undefined
15
+ setPromptRef: (ref: TuiPromptRef | undefined) => void
16
+ submitHost: () => void
17
+ requestRender: () => void
18
+ }
19
+
20
+ export type PromptModule = {
21
+ id: string
22
+ order?: number
23
+ setup?: (ctx: PromptContext) => void | (() => void)
24
+ onPromptRef?: (ref: TuiPromptRef | undefined, ctx: PromptContext) => void
25
+ onSubmit?: (ctx: PromptContext) => boolean | void
26
+ renderAbove?: (ctx: PromptContext) => JSX.Element
27
+ renderBelow?: (ctx: PromptContext) => JSX.Element
28
+ renderRight?: (ctx: PromptContext) => JSX.Element
29
+ }
30
+
31
+ export type PromptRootProps = {
32
+ api: TuiPluginApi
33
+ slot: TuiSlotContext
34
+ kind: PromptKind
35
+ sessionID?: string
36
+ workspaceID?: string
37
+ visible?: boolean
38
+ disabled?: boolean
39
+ onSubmit?: () => void
40
+ ref?: (ref: TuiPromptRef | undefined) => void
41
+ modules: PromptModule[]
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "preserve",
7
+ "jsxImportSource": "@opentui/solid",
8
+ "skipLibCheck": true,
9
+ "types": ["bun-types"]
10
+ },
11
+ "include": ["./tui.tsx", "./src/**/*.ts", "./src/**/*.tsx"]
12
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://opencode.ai/tui.json",
3
+ "plugin": [
4
+ [
5
+ "./plugin/opencode-vim",
6
+ {
7
+ "vim": {
8
+ "defaultMode": "insert",
9
+ "keymapTimeout": 500,
10
+ "pendingDisplayDelay": 120,
11
+ "cursorStyles": {
12
+ "insert": {
13
+ "style": "line",
14
+ "blinking": true
15
+ },
16
+ "normal": {
17
+ "style": "block",
18
+ "blinking": true
19
+ }
20
+ },
21
+ "debug": false,
22
+ "debugPath": "/home/you/.cache/opencode/opencode-vim.log",
23
+ "keymaps": {
24
+ "insert": {
25
+ "kj": "normal"
26
+ },
27
+ "normal": {
28
+ "i": "insert",
29
+ "<CR>": "submit",
30
+ "Y": "y$"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ]
36
+ ]
37
+ }
package/tui.tsx ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/plugin"