opencode-vim 0.0.23 → 0.0.24

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.
Files changed (38) hide show
  1. package/README.md +11 -0
  2. package/dist/tui.js +3326 -0
  3. package/package.json +6 -6
  4. package/src/clipboard.tsx +0 -50
  5. package/src/modules/snippets/index.tsx +0 -17
  6. package/src/modules/snippets/loader.ts +0 -147
  7. package/src/modules/snippets/search.ts +0 -118
  8. package/src/modules/snippets/skill-loader.ts +0 -81
  9. package/src/modules/snippets/state.ts +0 -40
  10. package/src/modules/snippets/trigger.ts +0 -68
  11. package/src/modules/snippets/types.ts +0 -50
  12. package/src/modules/snippets/view.tsx +0 -674
  13. package/src/modules/vim/actions.ts +0 -82
  14. package/src/modules/vim/config.ts +0 -137
  15. package/src/modules/vim/edit.test.ts +0 -51
  16. package/src/modules/vim/edit.ts +0 -33
  17. package/src/modules/vim/graphemes.ts +0 -38
  18. package/src/modules/vim/index.tsx +0 -200
  19. package/src/modules/vim/keys.ts +0 -38
  20. package/src/modules/vim/log.ts +0 -37
  21. package/src/modules/vim/map.test.ts +0 -45
  22. package/src/modules/vim/map.ts +0 -90
  23. package/src/modules/vim/state.test.ts +0 -18
  24. package/src/modules/vim/state.tsx +0 -46
  25. package/src/modules/vim/view.tsx +0 -130
  26. package/src/modules/vim/vimee.test.ts +0 -211
  27. package/src/modules/vim/vimee.ts +0 -1211
  28. package/src/plugin.tsx +0 -116
  29. package/src/prompt/host.tsx +0 -38
  30. package/src/prompt/modules.ts +0 -16
  31. package/src/prompt/root.tsx +0 -69
  32. package/src/prompt/types.ts +0 -42
  33. package/src/session.tsx +0 -291
  34. package/src/transcript-items.tsx +0 -168
  35. package/src/transcript.tsx +0 -179
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -340
  38. package/view.tsx +0 -29
@@ -1,168 +0,0 @@
1
- // Mirrors OpenCode's routes/session/{rows,group-view,anchor-view}.tsx.
2
- // TSX keeps OpenTUI classes shared with the host under the runtime loader.
3
- import type { Context } from "@opencode/plugin/tui/context"
4
- import { MarkdownRenderable, TextBufferRenderable, type Renderable } from "@opentui/core"
5
-
6
- type Message = ReturnType<Context["data"]["session"]["message"]["list"]>[number]
7
- type GroupKind = "reasoning" | "exploration" | "activity" | "instructions"
8
- export type TranscriptItem = { id: string; author: string; text: string }
9
- export type TranscriptRange = TranscriptItem & {
10
- node: Renderable
11
- top: number
12
- bottom: number
13
- toggle?: Renderable
14
- parentID?: string
15
- }
16
-
17
- type Part = TranscriptItem & { messageID: string; kind: string; group?: GroupKind }
18
-
19
- function parts(messages: Message[]) {
20
- const result: Part[] = []
21
- for (const message of messages) {
22
- if (message.type === "assistant") {
23
- const ordinal = { text: 0, reasoning: 0 }
24
- for (const part of message.content) {
25
- const partID = part.type === "tool" ? part.id : `${part.type}:${ordinal[part.type]++}`
26
- let text = part.type === "tool" ? "" : part.text
27
- if (part.type === "reasoning") text = text.replace("[REDACTED]", "")
28
- if (part.type !== "tool" && !text.trim()) continue
29
- result.push({ id: `session-part:${message.id}:${partID}`, messageID: message.id, kind: part.type,
30
- author: part.type === "text" ? "Assistant" : part.type === "reasoning" ? "Reasoning" : part.name,
31
- text,
32
- group: part.type === "reasoning" ? "reasoning"
33
- : part.type === "tool" && ["read", "glob", "grep", "webfetch", "websearch"].includes(part.name.toLowerCase())
34
- ? "exploration" : undefined })
35
- }
36
- // Turn footers prevent groups from spanning into the next assistant turn.
37
- if ((message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") || message.error || message.retry) {
38
- result.push({ id: `footer:${message.id}`, messageID: message.id, kind: "footer", author: "", text: "" })
39
- }
40
- } else {
41
- result.push({ id: message.id, messageID: message.id, kind: "message", author: message.type === "user" ? "You" : message.type,
42
- text: message.type === "user" ? message.text : "" })
43
- }
44
- }
45
- return result
46
- }
47
-
48
- function textOf(node: Renderable): string {
49
- if (!node.visible) return ""
50
- if (node instanceof MarkdownRenderable) return node.content
51
- if (node instanceof TextBufferRenderable) return node.plainText
52
- let text = ""
53
- let previous: Renderable | undefined
54
- for (const child of node.getChildren()) {
55
- const value = textOf(child)
56
- if (!value) continue
57
- if (text) text += previous?.y === child.y ? " " : "\n"
58
- text += value
59
- previous = child
60
- }
61
- return text
62
- }
63
-
64
- export function groupAt(node: Renderable) {
65
- const header = node.getChildren()[0]
66
- if (!header) return
67
- const body = header.getChildren()[0]
68
- let label: string
69
- if (body instanceof TextBufferRenderable) {
70
- if (!body.plainText.startsWith("⋯ ")) return
71
- label = body.plainText.slice(2).trim()
72
- } else {
73
- const cells = body?.getChildren() ?? []
74
- if (cells.length < 2 || !cells.slice(1).every((cell) => cell instanceof TextBufferRenderable)) return
75
- label = cells.slice(1).map((cell) => (cell as TextBufferRenderable).plainText).join(" ").trim()
76
- }
77
- let kind: GroupKind
78
- if (/^(Thought|Thinking)(?:$|[: ·…])/.test(label)) kind = "reasoning"
79
- else if (/^Explor(?:ed|ing):/.test(label)) kind = "exploration"
80
- else if (/^Instructions:/.test(label)) kind = "instructions"
81
- else if (/^(?:\d+ (?:commands?|edits?|thoughts?|reads?|tools?|instructions?)(?:, |$))+$/.test(label)
82
- || /^(?:Running|Preparing) .+…$/.test(label)) kind = "activity"
83
- else return
84
- return { header, kind, label, children: node.getChildren().slice(1) }
85
- }
86
-
87
- export function transcriptItems(rows: Renderable[], messages: Message[]) {
88
- const sources = parts(messages)
89
- const ranges: TranscriptRange[] = []
90
- let cursor = 0
91
- let lastSource = -1
92
-
93
- function add(node: Renderable, source?: Part, parentID?: string) {
94
- const text = source?.text || textOf(node)
95
- if (!text.trim()) return
96
- ranges.push({ id: source?.id ?? node.id, author: source?.author ?? "Tool", text, node,
97
- top: node.y, bottom: node.y + node.height, parentID })
98
- }
99
-
100
- function group(node: Renderable, parentID?: string): boolean {
101
- const info = groupAt(node)
102
- if (!info) return false
103
- const id = node.id
104
- ranges.push({ id, author: info.label, text: info.label, node: info.header,
105
- top: info.header.y, bottom: info.header.y + info.header.height, toggle: info.header, parentID })
106
- let children = info.children
107
- // Only exploration groups have EntryAnchors directly under GroupAnchor.
108
- if (info.kind !== "exploration" && children.length) {
109
- children = [...children[0].getChildren(), ...children.slice(1)]
110
- }
111
- for (const child of children) {
112
- if (!child.visible || !child.height) continue
113
- if (!group(child, id)) add(child, undefined, id)
114
- }
115
- return true
116
- }
117
-
118
- for (const row of rows) {
119
- if (row.id === "session-navigation-slack") break
120
- if (!row.visible || !row.height) continue
121
- const anchor = row.getChildren()[0]
122
- const info = anchor && groupAt(anchor)
123
- // Groups can consume a message's first parts without claiming its row ID.
124
- let index = sources.findIndex((part) => part.id === row.id)
125
- if (index === -1) {
126
- index = sources.findIndex((part, position) => position >= cursor && part.messageID === row.id)
127
- if (index !== -1 && !info) {
128
- const text = textOf(row).trim()
129
- const match = sources.findIndex((part, position) => position >= cursor && part.messageID === row.id && part.text.trim() === text)
130
- if (text && match !== -1) index = match
131
- }
132
- }
133
- if (index !== -1) cursor = index
134
- if (!info && index === -1 && sources[cursor]?.kind === "footer") {
135
- lastSource = cursor++
136
- continue
137
- }
138
- const container = anchor && !(anchor instanceof TextBufferRenderable) && !(anchor instanceof MarkdownRenderable)
139
- if (index === -1 && !info && sources[cursor]?.group && container) index = cursor
140
- if (anchor && info) {
141
- group(anchor)
142
- // Track grouped sources to detect newer cached rows that aren't mounted yet.
143
- while (cursor < sources.length) {
144
- const part = sources[cursor]
145
- if (info.kind === "activity" ? part.kind !== "tool" && part.kind !== "reasoning" : part.group !== info.kind) break
146
- lastSource = cursor++
147
- }
148
- } else if (index !== -1) {
149
- const source = sources[index]
150
- if (source.kind === "footer") {
151
- lastSource = cursor++
152
- } else if (source.group && container && anchor.getChildren().length) {
153
- // With grouping disabled, the GroupAnchor holds individual EntryAnchors.
154
- for (const child of anchor.getChildren()) {
155
- add(child, sources[cursor])
156
- cursor++
157
- }
158
- lastSource = cursor - 1
159
- } else {
160
- add(row, source)
161
- lastSource = cursor++
162
- }
163
- }
164
- }
165
- let last = sources.length - 1
166
- while (last >= 0 && (sources[last].kind === "message" || sources[last].kind === "footer") && !sources[last].text) last--
167
- return { ranges, hasLatest: lastSource >= last }
168
- }
@@ -1,179 +0,0 @@
1
- // TSX ensures the plugin loader shares the host's OpenTUI classes.
2
- import type { Context } from "@opencode/plugin/tui/context"
3
- import { MouseEvent, ScrollBoxRenderable, type OptimizedBuffer, type Renderable } from "@opentui/core"
4
- import { groupAt, transcriptItems, type TranscriptRange } from "./transcript-items"
5
-
6
- export function createTranscriptSelection(context: Context, sessionID: string, select: (id: string | undefined) => void) {
7
- let scroll: ScrollBoxRenderable | undefined
8
- let requestedID: string | undefined
9
- let selectedParents: string[] = []
10
- let navigation: {
11
- from: string | undefined
12
- offset: number
13
- latest?: boolean
14
- loading?: { key: string; frame: number; up: boolean; height: number }
15
- } | undefined
16
-
17
- function choose(range: TranscriptRange, view: NonNullable<ReturnType<typeof inspect>>) {
18
- requestedID = range.id
19
- select(requestedID)
20
- let delta = 0
21
- if (range.bottom <= view.top) delta = range.top - view.top
22
- else if (range.top >= view.bottom) {
23
- delta = range.top - view.top - Math.max(0, view.bottom - view.top - (range.bottom - range.top))
24
- }
25
- if (delta) {
26
- const slack = view.scroll.getRenderable("session-navigation-slack")?.height ?? 0
27
- const maximum = Math.max(0, view.scroll.scrollHeight - slack - view.scroll.viewport.height)
28
- const position = Math.max(0, Math.min(maximum, view.scroll.scrollTop + delta))
29
- if (slack) context.keymap.dispatch(delta < 0 ? "session.line.up" : "session.line.down")
30
- view.scroll.scrollTo(position)
31
- }
32
- context.renderer.requestRender()
33
- }
34
-
35
- function advance() {
36
- if (!navigation) return
37
- const view = inspect()
38
- const ranges = view?.ranges ?? []
39
- const from = navigation.from ? ranges.findIndex((range) => range.id === navigation!.from) : ranges.length
40
- if (navigation.from && from === -1) { navigation = undefined; return }
41
- const target = navigation.latest ? ranges.length - 1 : from + navigation.offset
42
- const loading = navigation.loading
43
- // OpenCode compensates for prepended rows after two animation frames.
44
- if (loading && context.renderer.frameId < loading.frame) {
45
- context.renderer.requestRender()
46
- return
47
- }
48
- if (view && loading?.up && view.scroll.scrollHeight > loading.height) {
49
- const maximum = Math.max(0, view.scroll.scrollHeight - view.scroll.viewport.height)
50
- const compensated = Math.max(0, Math.min(maximum, view.scroll.scrollHeight - loading.height - 1))
51
- if (view.scroll.scrollTop < compensated) return
52
- }
53
- if (view && target >= 0 && target < ranges.length && (!navigation.latest || view.hasLatest)) {
54
- navigation = undefined
55
- choose(ranges[target], view)
56
- return
57
- }
58
- const up = !navigation.latest && target < 0
59
- const messages = context.data.session.message.list(sessionID)
60
- const key = `${messages[0]?.id}:${messages.length}:${ranges[0]?.id}:${ranges.at(-1)?.id}:${view?.scroll.scrollHeight}`
61
- if (loading?.key === key) return
62
- navigation.loading = { key, frame: context.renderer.frameId + 2, up, height: view?.scroll.scrollHeight ?? 0 }
63
- view?.scroll.scrollTo(up ? 0 : view.scroll.scrollHeight)
64
- context.keymap.dispatch(up ? "session.line.up" : "session.line.down")
65
- context.renderer.requestRender()
66
- }
67
-
68
- function latest() {
69
- if (!context.data.session.message.list(sessionID).length) {
70
- navigation = undefined
71
- requestedID = undefined
72
- select(undefined)
73
- return
74
- }
75
- navigation = { from: undefined, offset: 0, latest: true }
76
- advance()
77
- }
78
-
79
- function move(direction: "next" | "previous", count: number) {
80
- if (navigation && (navigation.latest || (navigation.offset < 0 && direction === "next") || (navigation.offset > 0 && direction === "previous"))) {
81
- navigation = undefined
82
- }
83
- navigation ??= { from: requestedID ?? inspect()?.selected?.id, offset: 0 }
84
- navigation.offset += direction === "next" ? count : -count
85
- if (navigation.loading) navigation.loading.key = ""
86
- advance()
87
- }
88
-
89
- function inspect() {
90
- const messages = context.data.session.message.list(sessionID)
91
- const ids = new Set(messages.map((message) => message.id))
92
- function isRow(node: Renderable) {
93
- if (ids.has(node.id) || (node.id?.startsWith("session-part:") && ids.has(node.id.split(":")[1]))) return true
94
- const anchor = node.getChildren()[0]
95
- return Boolean(anchor && groupAt(anchor))
96
- }
97
- function findScroll(node: Renderable): ScrollBoxRenderable | undefined {
98
- if (!node.visible) return
99
- if (node instanceof ScrollBoxRenderable && node.getChildren().some(isRow)) return node
100
- for (const child of node.getChildren()) {
101
- const found = findScroll(child)
102
- if (found) return found
103
- }
104
- }
105
- if (!scroll || scroll.isDestroyed) scroll = findScroll(context.renderer.root)
106
- if (!scroll) { select(undefined); return }
107
- const { ranges, hasLatest } = transcriptItems(scroll.getChildren(), messages)
108
- const top = scroll.viewport.y
109
- const bottom = top + scroll.viewport.height
110
- let selected = ranges.find((range) => range.id === requestedID)
111
- if (!selected && requestedID) {
112
- for (const id of selectedParents) {
113
- selected = ranges.find((range) => range.id === id)
114
- if (selected) { requestedID = id; break }
115
- }
116
- if (!selected) requestedID = undefined
117
- }
118
- selected ??= ranges.find((range) => range.top <= top + 1 && range.bottom > top + 1)
119
- ?? ranges.find((range) => range.bottom > top && range.top < bottom)
120
- selectedParents = []
121
- let parent = selected?.parentID
122
- while (parent) {
123
- selectedParents.push(parent)
124
- parent = ranges.find((range) => range.id === parent)?.parentID
125
- }
126
- select(selected?.id)
127
- return { scroll, selected, ranges, hasLatest, top, bottom }
128
- }
129
-
130
- return {
131
- latest, move,
132
- get(id: string | undefined) {
133
- if (!scroll || scroll.isDestroyed) return
134
- return transcriptItems(scroll.getChildren(), context.data.session.message.list(sessionID)).ranges.find((range) => range.id === id)
135
- },
136
- toggle() {
137
- const range = inspect()?.selected
138
- if (!range?.toggle) return false
139
- navigation = undefined
140
- requestedID = range.id
141
- const node = range.toggle
142
- node.processMouseEvent(new MouseEvent(node, { type: "up", button: 0, x: node.x, y: node.y,
143
- modifiers: { shift: false, alt: false, ctrl: false } }))
144
- context.renderer.requestRender()
145
- return true
146
- },
147
- followViewport() { navigation = undefined; requestedID = undefined; selectedParents = [] },
148
- focus() { inspect()?.scroll.focus() },
149
- sync() { advance(); navigation = undefined; return inspect() },
150
- draw(buffer: OptimizedBuffer, yankID?: string) {
151
- advance()
152
- const view = inspect()
153
- if (!view?.selected) return
154
- const top = Math.max(0, view.top, view.selected.top)
155
- const bottom = Math.min(buffer.height, view.bottom, view.selected.bottom)
156
- if (view.selected.id === yankID) {
157
- const node = view.selected.node
158
- const left = Math.max(0, view.scroll.viewport.x, node.x)
159
- const right = Math.min(buffer.width, view.scroll.viewport.x + view.scroll.viewport.width, node.x + node.width)
160
- const foreground = context.theme.background.base.buffer
161
- const background = context.theme.text.feedback.info.base.buffer
162
- const colors = buffer.buffers
163
- // Change only colors to preserve wide-character continuation cells.
164
- for (let y = top; y < bottom; y++) {
165
- for (let x = left; x < right; x++) {
166
- const offset = (y * buffer.width + x) * 4
167
- colors.fg.set(foreground, offset)
168
- colors.bg.set(background, offset)
169
- }
170
- }
171
- }
172
- const x = view.scroll.x - 1
173
- if (x < 0) return
174
- for (let y = top; y < bottom; y++) {
175
- buffer.drawText("▎", x, y, context.theme.text.feedback.info.base, context.theme.background.base)
176
- }
177
- },
178
- }
179
- }
package/src/update.ts DELETED
@@ -1,120 +0,0 @@
1
- import { rm } from "node:fs/promises"
2
- import { basename, dirname, join } from "node:path"
3
- import { fileURLToPath } from "node:url"
4
- import type { TuiPluginMeta } from "@opencode-ai/plugin/tui"
5
-
6
- type PackageJson = {
7
- name?: string
8
- version?: string
9
- dependencies?: Record<string, string>
10
- }
11
-
12
- type UpdateResult =
13
- | { updated: true; name: string; current: string; latest: string }
14
- | { updated: false; error: "remove_failed"; name: string; current: string; latest: string }
15
- | { updated: false }
16
-
17
- export async function checkAutoUpdate(meta: TuiPluginMeta, signal: AbortSignal): Promise<UpdateResult> {
18
- if (meta.source !== "npm") return { updated: false }
19
-
20
- const packageDir = await findPackageDir()
21
- if (!packageDir) return { updated: false }
22
-
23
- const pkg = await readPackageJson(join(packageDir, "package.json"))
24
- if (!pkg.name || !pkg.version) return { updated: false }
25
-
26
- const latest = await fetchLatestVersion(pkg.name, signal)
27
- if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false }
28
-
29
- const removeDir = await updateRemoveDir(packageDir, pkg.name)
30
- try {
31
- await rm(removeDir, { recursive: true, force: true })
32
- } catch {
33
- return { updated: false, error: "remove_failed", name: pkg.name, current: pkg.version, latest }
34
- }
35
-
36
- return { updated: true, name: pkg.name, current: pkg.version, latest }
37
- }
38
-
39
- async function findPackageDir() {
40
- let dir = dirname(fileURLToPath(import.meta.url))
41
- for (;;) {
42
- const packagePath = join(dir, "package.json")
43
- const pkg = await readPackageJson(packagePath)
44
- if (pkg?.name === "opencode-vim") return dir
45
-
46
- const parent = dirname(dir)
47
- if (parent === dir) return undefined
48
- dir = parent
49
- }
50
- }
51
-
52
- async function updateRemoveDir(packageDir: string, name: string) {
53
- const nodeModulesDir = dirname(packageDir)
54
- if (basename(nodeModulesDir) !== "node_modules") return packageDir
55
-
56
- const wrapperDir = dirname(nodeModulesDir)
57
- const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
58
- return wrapperPkg?.dependencies?.[name] ? wrapperDir : packageDir
59
- }
60
-
61
- async function readPackageJson(path: string): Promise<PackageJson | undefined> {
62
- try {
63
- const file = Bun.file(path)
64
- if (!(await file.exists())) return undefined
65
- const data = await file.json()
66
- return data && typeof data === "object" ? (data as PackageJson) : undefined
67
- } catch {
68
- return undefined
69
- }
70
- }
71
-
72
- async function fetchLatestVersion(name: string, signal: AbortSignal) {
73
- try {
74
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { signal })
75
- if (!response.ok) return undefined
76
- const data = await response.json()
77
- return data && typeof data === "object" && typeof data.version === "string" ? data.version : undefined
78
- } catch {
79
- return undefined
80
- }
81
- }
82
-
83
- function isVersionNewer(latest: string, current: string) {
84
- const next = parseVersion(latest)
85
- const prev = parseVersion(current)
86
- if (!next || !prev) return false
87
-
88
- for (let i = 0; i < 3; i++) {
89
- if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i]
90
- }
91
-
92
- if (!next.pre.length && prev.pre.length) return true
93
- if (next.pre.length && !prev.pre.length) return false
94
-
95
- for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) {
96
- const a = next.pre[i]
97
- const b = prev.pre[i]
98
- if (a === undefined) return false
99
- if (b === undefined) return true
100
- if (a === b) continue
101
-
102
- const aNumber = /^\d+$/.test(a) ? Number(a) : undefined
103
- const bNumber = /^\d+$/.test(b) ? Number(b) : undefined
104
- if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber
105
- if (aNumber !== undefined) return false
106
- if (bNumber !== undefined) return true
107
- return a > b
108
- }
109
-
110
- return false
111
- }
112
-
113
- function parseVersion(version: string) {
114
- const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/)
115
- if (!match) return undefined
116
- return {
117
- parts: [Number(match[1]), Number(match[2]), Number(match[3])],
118
- pre: match[4]?.split(".") ?? [],
119
- }
120
- }