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.
@@ -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
+ }