opencode-vim 0.0.22 → 0.0.23
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.
- package/README.md +17 -66
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +1 -1
- package/src/clipboard.tsx +50 -0
- package/src/modules/vim/actions.ts +1 -4
- package/src/modules/vim/config.ts +12 -0
- package/src/modules/vim/map.ts +13 -121
- package/src/modules/vim/vimee.ts +228 -163
- package/src/session.tsx +291 -0
- package/src/transcript-items.tsx +168 -0
- package/src/transcript.tsx +179 -0
- package/tui.tsx +73 -8
- package/view.tsx +1 -1
package/src/session.tsx
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
5
|
+
import { createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js"
|
|
6
|
+
import type { PromptContext } from "./modules/vim/actions"
|
|
7
|
+
import type { VimConfig } from "./modules/vim/config"
|
|
8
|
+
import { keyNotation } from "./modules/vim/keys"
|
|
9
|
+
import { displayWidth } from "./modules/vim/map"
|
|
10
|
+
import { createVimState } from "./modules/vim/state"
|
|
11
|
+
import { createVimeeAdapter, YANK_FLASH_MS } from "./modules/vim/vimee"
|
|
12
|
+
import { createTranscriptSelection } from "./transcript"
|
|
13
|
+
import type { TranscriptItem } from "./transcript-items"
|
|
14
|
+
import type { VimClipboard } from "./clipboard"
|
|
15
|
+
|
|
16
|
+
export const SESSION_MODE = "opencode-vim.session"
|
|
17
|
+
|
|
18
|
+
export function createSessionMode(context: Context, config: VimConfig, clipboard: VimClipboard) {
|
|
19
|
+
const [active, setActive] = createSignal(false)
|
|
20
|
+
const [reading, setReading] = createSignal<TranscriptItem>()
|
|
21
|
+
const [notice, setNotice] = createSignal("")
|
|
22
|
+
const positions = new Map<string, number>()
|
|
23
|
+
let sessionID = ""
|
|
24
|
+
let selectedID: string | undefined
|
|
25
|
+
let prompt: Context["renderer"]["currentFocusedEditor"] = null
|
|
26
|
+
let transcript: ReturnType<typeof createTranscriptSelection> | undefined
|
|
27
|
+
let popMode: (() => void) | undefined
|
|
28
|
+
let prefix = ""
|
|
29
|
+
let count = ""
|
|
30
|
+
let disposed = false
|
|
31
|
+
let noticeTimer: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
let yankTimer: ReturnType<typeof setTimeout> | undefined
|
|
33
|
+
let yankID: string | undefined
|
|
34
|
+
|
|
35
|
+
function selected() {
|
|
36
|
+
return transcript?.get(selectedID)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearYankFlash() {
|
|
40
|
+
if (yankTimer) clearTimeout(yankTimer)
|
|
41
|
+
yankTimer = undefined
|
|
42
|
+
if (!yankID) return
|
|
43
|
+
yankID = undefined
|
|
44
|
+
context.renderer.requestRender()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
if (!active()) return
|
|
49
|
+
clearYankFlash()
|
|
50
|
+
const wasReading = Boolean(reading())
|
|
51
|
+
if (wasReading) context.ui.dialog.clear()
|
|
52
|
+
setActive(false)
|
|
53
|
+
context.renderer.removePostProcessFn(draw)
|
|
54
|
+
popMode?.()
|
|
55
|
+
popMode = undefined
|
|
56
|
+
transcript = undefined
|
|
57
|
+
const savedPrompt = prompt
|
|
58
|
+
const savedSessionID = sessionID
|
|
59
|
+
function restorePrompt() {
|
|
60
|
+
const route = context.ui.router.current()
|
|
61
|
+
if (!active() && context.keymap.mode.current() === "base" && route.type === "session"
|
|
62
|
+
&& route.sessionID === savedSessionID && savedPrompt && !savedPrompt.isDestroyed) savedPrompt.focus()
|
|
63
|
+
}
|
|
64
|
+
restorePrompt()
|
|
65
|
+
// Restore after the dialog's deferred refocus, or it steals focus back.
|
|
66
|
+
if (wasReading) setTimeout(restorePrompt, 1)
|
|
67
|
+
prompt = null
|
|
68
|
+
context.renderer.requestRender()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function enter() {
|
|
72
|
+
const route = context.ui.router.current()
|
|
73
|
+
if (route.type !== "session") return false
|
|
74
|
+
sessionID = route.sessionID
|
|
75
|
+
prompt = context.renderer.currentFocusedEditor
|
|
76
|
+
prefix = count = ""
|
|
77
|
+
positions.clear()
|
|
78
|
+
setNotice("")
|
|
79
|
+
popMode = context.keymap.mode.push(SESSION_MODE)
|
|
80
|
+
transcript = createTranscriptSelection(context, sessionID, (id) => {
|
|
81
|
+
selectedID = id
|
|
82
|
+
})
|
|
83
|
+
setActive(true)
|
|
84
|
+
prompt?.blur()
|
|
85
|
+
transcript.focus()
|
|
86
|
+
transcript.latest()
|
|
87
|
+
context.renderer.addPostProcessFn(draw)
|
|
88
|
+
context.renderer.requestRender()
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const draw: Parameters<Context["renderer"]["addPostProcessFn"]>[0] = (buffer) => {
|
|
93
|
+
if (active() && !reading() && context.keymap.mode.current() === SESSION_MODE) transcript?.draw(buffer, yankID)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function copy(text: string) {
|
|
97
|
+
const copied = await clipboard.write(text)
|
|
98
|
+
if (disposed) return
|
|
99
|
+
if (noticeTimer) clearTimeout(noticeTimer)
|
|
100
|
+
setNotice(copied ? "Copied" : "Yanked · clipboard unavailable")
|
|
101
|
+
noticeTimer = setTimeout(() => setNotice(""), 1500)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function openMessage(message: TranscriptItem) {
|
|
105
|
+
transcript?.focus()
|
|
106
|
+
setReading(message)
|
|
107
|
+
context.ui.dialog.show(() => (
|
|
108
|
+
<MessageReader context={context} config={config} message={message} offset={positions.get(message.id) ?? 0}
|
|
109
|
+
copy={copy} notice={notice} remember={(offset) => positions.set(message.id, offset)}
|
|
110
|
+
back={() => context.ui.dialog.clear()} close={close} />
|
|
111
|
+
), () => {
|
|
112
|
+
setReading(undefined)
|
|
113
|
+
setNotice("")
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const onKey = (event: KeyEvent) => {
|
|
118
|
+
if (!active() || reading()) return
|
|
119
|
+
const key = readerKey(context, event, config.sessionKey)
|
|
120
|
+
if (!key) return
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
event.stopPropagation()
|
|
123
|
+
clearYankFlash()
|
|
124
|
+
setNotice("")
|
|
125
|
+
if (key === config.sessionKey || key === "<Esc>" || key === "<C-[>") { close(); return }
|
|
126
|
+
if (/^[0-9]$/.test(key) && (count || key !== "0")) { count = (count + key).slice(0, 6); return }
|
|
127
|
+
const amount = Number(count) || 1
|
|
128
|
+
count = ""
|
|
129
|
+
const previous = prefix
|
|
130
|
+
prefix = ""
|
|
131
|
+
let command = pageCommand(key)
|
|
132
|
+
if (key === "j" || key === "<Down>") { transcript?.move("next", amount); return }
|
|
133
|
+
else if (key === "k" || key === "<Up>") { transcript?.move("previous", amount); return }
|
|
134
|
+
else if (key === "G") { transcript?.latest(); return }
|
|
135
|
+
else if (key === "g" && previous === "g") command = "session.first"
|
|
136
|
+
else if (key === "y" && previous === "y") {
|
|
137
|
+
const message = selected()
|
|
138
|
+
if (message) {
|
|
139
|
+
yankID = message.id
|
|
140
|
+
yankTimer = setTimeout(clearYankFlash, YANK_FLASH_MS)
|
|
141
|
+
context.renderer.requestRender()
|
|
142
|
+
void copy(message.text)
|
|
143
|
+
}
|
|
144
|
+
} else if (key === "g" || key === "y") prefix = key
|
|
145
|
+
else if (key === "<CR>") {
|
|
146
|
+
transcript?.sync()
|
|
147
|
+
if (transcript?.toggle()) return
|
|
148
|
+
const message = selected()
|
|
149
|
+
if (message) openMessage(message)
|
|
150
|
+
}
|
|
151
|
+
if (command) {
|
|
152
|
+
transcript?.followViewport()
|
|
153
|
+
for (let index = 0; index < amount; index++) context.keymap.dispatch(command)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
createEffect(() => {
|
|
158
|
+
const route = context.ui.router.current()
|
|
159
|
+
if (active() && (route.type !== "session" || route.sessionID !== sessionID)) untrack(close)
|
|
160
|
+
})
|
|
161
|
+
onMount(() => context.renderer.keyInput.prependListener("keypress", onKey))
|
|
162
|
+
onCleanup(() => {
|
|
163
|
+
disposed = true
|
|
164
|
+
close()
|
|
165
|
+
if (noticeTimer) clearTimeout(noticeTimer)
|
|
166
|
+
context.renderer.keyInput.off("keypress", onKey)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
active, enter, close,
|
|
171
|
+
Status() {
|
|
172
|
+
return <text fg={context.theme.text.feedback.info.base}>
|
|
173
|
+
{notice() || `SESSION · Enter open · yy copy · ${config.sessionKey} prompt`}
|
|
174
|
+
</text>
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function MessageReader(props: {
|
|
180
|
+
context: Context
|
|
181
|
+
config: VimConfig
|
|
182
|
+
message: TranscriptItem
|
|
183
|
+
offset: number
|
|
184
|
+
copy: (text: string) => void
|
|
185
|
+
notice: () => string
|
|
186
|
+
remember: (offset: number) => void
|
|
187
|
+
back: () => void
|
|
188
|
+
close: () => void
|
|
189
|
+
}) {
|
|
190
|
+
const context = props.context
|
|
191
|
+
context.ui.dialog.set({ size: "large", centered: true })
|
|
192
|
+
const dimensions = useTerminalDimensions()
|
|
193
|
+
const theme = () => context.theme.surface("dialog")
|
|
194
|
+
const background = () => theme().background.raised.high
|
|
195
|
+
const state = createVimState("normal")
|
|
196
|
+
const [line, setLine] = createSignal(1)
|
|
197
|
+
let input!: TextareaRenderable
|
|
198
|
+
const adapter = createVimeeAdapter(state, props.config, () => {}, { readOnly: true, onYank: props.copy })
|
|
199
|
+
const editorContext: PromptContext = {
|
|
200
|
+
api: {
|
|
201
|
+
renderer: { get currentFocusedRenderable() { return input } },
|
|
202
|
+
keymap: { dispatchCommand: () => ({ ok: false }) },
|
|
203
|
+
theme: { get current() { return {
|
|
204
|
+
warning: theme().text.feedback.warning.base,
|
|
205
|
+
info: theme().text.feedback.info.base,
|
|
206
|
+
background: background(),
|
|
207
|
+
} } },
|
|
208
|
+
},
|
|
209
|
+
prompt: () => ({
|
|
210
|
+
get current() { return { input: input.plainText, mode: "normal", parts: [] } },
|
|
211
|
+
set() {}, submit() {}, blur() {},
|
|
212
|
+
}),
|
|
213
|
+
requestRender: () => context.renderer.requestRender(),
|
|
214
|
+
}
|
|
215
|
+
const onKey = (event: KeyEvent) => {
|
|
216
|
+
if (context.renderer.currentFocusedEditor !== input) return
|
|
217
|
+
const key = readerKey(context, event, props.config.sessionKey, "modal")
|
|
218
|
+
if (!key) return
|
|
219
|
+
event.preventDefault()
|
|
220
|
+
event.stopPropagation()
|
|
221
|
+
if (key === props.config.sessionKey && state.mode() === "normal" && !adapter.isPending()) { props.close(); return }
|
|
222
|
+
// The host's Ctrl+C clears the editor unless we intercept it.
|
|
223
|
+
if (key === "<C-c>") { props.back(); return }
|
|
224
|
+
if ((key === "<Esc>" || key === "<C-[>") && state.mode() === "normal" && !adapter.isPending()) {
|
|
225
|
+
props.back()
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (pageCommand(key)) {
|
|
229
|
+
const down = key === "<C-d>" || key === "<C-f>" || key === "<PageDown>"
|
|
230
|
+
const half = key === "<C-d>" || key === "<C-u>"
|
|
231
|
+
const rows = Math.max(1, Math.floor(input.height / (half ? 2 : 1)))
|
|
232
|
+
for (let row = 0; row < rows; row++) adapter.handle({ ...event, ctrl: false } as KeyEvent, down ? "j" : "k", editorContext)
|
|
233
|
+
} else adapter.handle(event, key, editorContext)
|
|
234
|
+
setLine(input.logicalCursor.row + 1)
|
|
235
|
+
}
|
|
236
|
+
onMount(() => {
|
|
237
|
+
input.cursorOffset = Math.min(props.offset, displayWidth(input.plainText))
|
|
238
|
+
adapter.attach(editorContext)
|
|
239
|
+
setLine(input.logicalCursor.row + 1)
|
|
240
|
+
input.focus()
|
|
241
|
+
context.renderer.keyInput.prependListener("keypress", onKey)
|
|
242
|
+
})
|
|
243
|
+
onCleanup(() => {
|
|
244
|
+
props.remember(input.cursorOffset)
|
|
245
|
+
context.renderer.keyInput.off("keypress", onKey)
|
|
246
|
+
adapter.cleanup()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
function modeLabel() {
|
|
250
|
+
if (state.mode() === "visual") return "VISUAL"
|
|
251
|
+
if (state.mode() === "visual-line") return "VISUAL LINE"
|
|
252
|
+
return "MESSAGE"
|
|
253
|
+
}
|
|
254
|
+
return (
|
|
255
|
+
<box id="vim-message-reader" gap={1}>
|
|
256
|
+
<box paddingLeft={2} paddingRight={2} flexDirection="row" justifyContent="space-between">
|
|
257
|
+
<text fg={theme().text.base}><b>{props.message.author}</b></text>
|
|
258
|
+
<text id="vim-message-close" fg={theme().text.muted} onMouseUp={props.back}>esc</text>
|
|
259
|
+
</box>
|
|
260
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={background()}>
|
|
261
|
+
<textarea id="vim-session-message" ref={(value: TextareaRenderable) => {
|
|
262
|
+
input = value
|
|
263
|
+
input.handleKeyPress = () => true
|
|
264
|
+
input.handlePaste = () => {}
|
|
265
|
+
}} initialValue={props.message.text} minHeight={1} maxHeight={Math.max(1, Math.min(20, dimensions().height - 10))}
|
|
266
|
+
wrapMode="word" showCursor cursorStyle={props.config.cursorStyles.normal} textColor={theme().text.base}
|
|
267
|
+
backgroundColor={background()} focusedBackgroundColor={background()} focusedTextColor={theme().text.base} />
|
|
268
|
+
</box>
|
|
269
|
+
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
|
|
270
|
+
<text fg={theme().text.muted}>{modeLabel()} · {line()}/{input?.lineCount ?? 1}</text>
|
|
271
|
+
<text fg={theme().text.muted}>
|
|
272
|
+
{props.notice() || (state.mode() !== "normal" ? "y copy · Esc cancel" : `v select · V lines · ${props.config.sessionKey} prompt`)}
|
|
273
|
+
</text>
|
|
274
|
+
</box>
|
|
275
|
+
</box>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readerKey(context: Context, event: KeyEvent, sessionKey: string, mode = SESSION_MODE) {
|
|
280
|
+
if (event.defaultPrevented || context.keymap.mode.current() !== mode) return
|
|
281
|
+
if (context.keymap.pending().length || event.super || event.meta) return
|
|
282
|
+
const key = keyNotation(event)
|
|
283
|
+
if (key && (key === sessionKey || !event.ctrl || key === "<C-[>" || pageCommand(key) || (mode === "modal" && key === "<C-c>"))) return key
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function pageCommand(key: string) {
|
|
287
|
+
if (key === "<C-d>") return "session.half.page.down"
|
|
288
|
+
if (key === "<C-u>") return "session.half.page.up"
|
|
289
|
+
if (key === "<C-f>" || key === "<PageDown>") return "session.page.down"
|
|
290
|
+
if (key === "<C-b>" || key === "<PageUp>") return "session.page.up"
|
|
291
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
}
|