Skip to content

Commit 1348836

Browse files
feat(session): implement bi-directional cursor pagination across API and TUI
Add cursor-based message retrieval (before/after/oldest) in session and API layers with RFC 8288 Link-header navigation while preserving response compatibility. Implement TUI pagination state, boundary-triggered loading for mouse and command paths, robust non-Error pagination error rendering, and revert-marker-safe oldest/latest jumps under bounded in-memory windows. Include regression coverage for session/server/link-header/TUI pagination flows and regenerate SDK/OpenAPI artifacts to match the updated contract.
1 parent b368181 commit 1348836

File tree

17 files changed

+1818
-106
lines changed

17 files changed

+1818
-106
lines changed

packages/opencode/src/cli/cmd/tui/context/keybind.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
1515
const keybinds = createMemo(() => {
1616
return pipe(
1717
sync.data.config.keybinds ?? {},
18-
mapValues((value) => Keybind.parse(value)),
18+
mapValues((value) => Keybind.parse(typeof value === "string" ? value : "none")),
1919
)
2020
})
2121
const [store, setStore] = createStore({

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 428 additions & 11 deletions
Large diffs are not rendered by default.

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 187 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
7878
import { DialogExportOptions } from "../../ui/dialog-export-options"
7979
import { formatTranscript } from "../../util/transcript"
8080
import { UI } from "@/cli/ui.ts"
81+
import { edgeHints, olderScrollTarget, queueBoundaryLoad } from "@tui/util/pagination"
8182

8283
addDefaultParsers(parsers.parsers)
8384

@@ -124,6 +125,7 @@ export function Session() {
124125
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
125126
})
126127
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
128+
const paging = createMemo(() => sync.data.message_page[route.sessionID])
127129
const permissions = createMemo(() => {
128130
if (session()?.parentID) return []
129131
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -133,6 +135,67 @@ export function Session() {
133135
return children().flatMap((x) => sync.data.question[x.id] ?? [])
134136
})
135137

138+
const LOAD_MORE_THRESHOLD = 5
139+
140+
const loadOlder = () => {
141+
const page = paging()
142+
if (!page?.hasOlder || page.loading || !scroll) return
143+
if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return
144+
145+
const anchor = (() => {
146+
const scrollTop = scroll.scrollTop
147+
const children = scroll.getChildren()
148+
for (const child of children) {
149+
if (!child.id) continue
150+
if (child.y + child.height > scrollTop) {
151+
return { id: child.id, offset: scrollTop - child.y }
152+
}
153+
}
154+
return undefined
155+
})()
156+
157+
const height = scroll.scrollHeight
158+
const scrollTop = scroll.scrollTop
159+
sync.session.loadOlder(route.sessionID).then(() => {
160+
queueMicrotask(() => {
161+
requestAnimationFrame(() => {
162+
if (!scroll || scroll.isDestroyed) return
163+
const nextTop = olderScrollTarget(scroll.getChildren(), scroll.scrollHeight, height, scrollTop, anchor)
164+
if (nextTop !== undefined) scroll.scrollTo(nextTop)
165+
refreshEdges()
166+
})
167+
})
168+
})
169+
}
170+
171+
const loadNewer = () => {
172+
const page = paging()
173+
if (!page?.hasNewer || page.loading || !scroll) return
174+
const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height
175+
if (bottomDistance > LOAD_MORE_THRESHOLD) return
176+
sync.session.loadNewer(route.sessionID).then(() => {
177+
queueMicrotask(() => {
178+
requestAnimationFrame(() => {
179+
refreshEdges()
180+
})
181+
})
182+
})
183+
}
184+
185+
const refreshEdges = () => {
186+
if (!scroll || scroll.isDestroyed) return
187+
const edges = edgeHints(scroll.scrollTop, scroll.scrollHeight, scroll.viewport.height, HINT_THRESHOLD)
188+
setNearTop(edges.nearTop)
189+
setNearBottom(edges.nearBottom)
190+
}
191+
192+
const scrollMove = (delta: number) => {
193+
if (!scroll || scroll.isDestroyed) return
194+
scroll.scrollBy(delta)
195+
refreshEdges()
196+
queueBoundaryLoad(delta, loadOlder, loadNewer)
197+
}
198+
136199
const pending = createMemo(() => {
137200
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
138201
})
@@ -154,6 +217,9 @@ export function Session() {
154217
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
155218
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
156219
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
220+
const [nearTop, setNearTop] = createSignal(false)
221+
const [nearBottom, setNearBottom] = createSignal(false)
222+
const HINT_THRESHOLD = 20
157223

158224
const wide = createMemo(() => dimensions().width > 120)
159225
const sidebarVisible = createMemo(() => {
@@ -181,7 +247,9 @@ export function Session() {
181247
await sync.session
182248
.sync(route.sessionID)
183249
.then(() => {
184-
if (scroll) scroll.scrollBy(100_000)
250+
if (!scroll || scroll.isDestroyed) return
251+
scroll.scrollBy(100_000)
252+
refreshEdges()
185253
})
186254
.catch((e) => {
187255
console.error(e)
@@ -193,6 +261,16 @@ export function Session() {
193261
})
194262
})
195263

264+
createEffect(() => {
265+
if (!scroll || scroll.isDestroyed) return
266+
messages()
267+
queueMicrotask(() => {
268+
requestAnimationFrame(() => {
269+
refreshEdges()
270+
})
271+
})
272+
})
273+
196274
const toast = useToast()
197275
const sdk = useSDK()
198276

@@ -258,7 +336,7 @@ export function Session() {
258336
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
259337
const children = scroll.getChildren()
260338
const messagesList = messages()
261-
const scrollTop = scroll.y
339+
const scrollTop = scroll.scrollTop
262340

263341
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
264342
const visibleMessages = children
@@ -290,20 +368,26 @@ export function Session() {
290368
const targetID = findNextVisibleMessage(direction)
291369

292370
if (!targetID) {
293-
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
371+
scrollMove(direction === "next" ? scroll.height : -scroll.height)
294372
dialog.clear()
295373
return
296374
}
297375

298376
const child = scroll.getChildren().find((c) => c.id === targetID)
299-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
377+
if (child) {
378+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
379+
refreshEdges()
380+
}
300381
dialog.clear()
301382
}
302383

303384
function toBottom() {
304385
setTimeout(() => {
305386
if (!scroll || scroll.isDestroyed) return
306387
scroll.scrollTo(scroll.scrollHeight)
388+
requestAnimationFrame(() => {
389+
refreshEdges()
390+
})
307391
}, 50)
308392
}
309393

@@ -381,7 +465,10 @@ export function Session() {
381465
const child = scroll.getChildren().find((child) => {
382466
return child.id === messageID
383467
})
384-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
468+
if (child) {
469+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
470+
refreshEdges()
471+
}
385472
}}
386473
sessionID={route.sessionID}
387474
setPrompt={(promptInfo) => prompt.set(promptInfo)}
@@ -404,7 +491,10 @@ export function Session() {
404491
const child = scroll.getChildren().find((child) => {
405492
return child.id === messageID
406493
})
407-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
494+
if (child) {
495+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
496+
refreshEdges()
497+
}
408498
}}
409499
sessionID={route.sessionID}
410500
/>
@@ -618,7 +708,7 @@ export function Session() {
618708
category: "Session",
619709
hidden: true,
620710
onSelect: (dialog) => {
621-
scroll.scrollBy(-scroll.height / 2)
711+
scrollMove(-scroll.height / 2)
622712
dialog.clear()
623713
},
624714
},
@@ -629,7 +719,7 @@ export function Session() {
629719
category: "Session",
630720
hidden: true,
631721
onSelect: (dialog) => {
632-
scroll.scrollBy(scroll.height / 2)
722+
scrollMove(scroll.height / 2)
633723
dialog.clear()
634724
},
635725
},
@@ -640,7 +730,7 @@ export function Session() {
640730
category: "Session",
641731
disabled: true,
642732
onSelect: (dialog) => {
643-
scroll.scrollBy(-1)
733+
scrollMove(-1)
644734
dialog.clear()
645735
},
646736
},
@@ -651,7 +741,7 @@ export function Session() {
651741
category: "Session",
652742
disabled: true,
653743
onSelect: (dialog) => {
654-
scroll.scrollBy(1)
744+
scrollMove(1)
655745
dialog.clear()
656746
},
657747
},
@@ -662,7 +752,7 @@ export function Session() {
662752
category: "Session",
663753
hidden: true,
664754
onSelect: (dialog) => {
665-
scroll.scrollBy(-scroll.height / 4)
755+
scrollMove(-scroll.height / 4)
666756
dialog.clear()
667757
},
668758
},
@@ -673,7 +763,7 @@ export function Session() {
673763
category: "Session",
674764
hidden: true,
675765
onSelect: (dialog) => {
676-
scroll.scrollBy(scroll.height / 4)
766+
scrollMove(scroll.height / 4)
677767
dialog.clear()
678768
},
679769
},
@@ -684,7 +774,23 @@ export function Session() {
684774
category: "Session",
685775
hidden: true,
686776
onSelect: (dialog) => {
687-
scroll.scrollTo(0)
777+
const page = paging()
778+
if (page?.hasOlder && !page.loading) {
779+
sync.session.jumpToOldest(route.sessionID).then(() => {
780+
requestAnimationFrame(() => {
781+
if (!scroll || scroll.isDestroyed) return
782+
scroll.scrollTo(0)
783+
refreshEdges()
784+
})
785+
})
786+
} else {
787+
if (!scroll || scroll.isDestroyed) {
788+
dialog.clear()
789+
return
790+
}
791+
scroll.scrollTo(0)
792+
refreshEdges()
793+
}
688794
dialog.clear()
689795
},
690796
},
@@ -695,7 +801,23 @@ export function Session() {
695801
category: "Session",
696802
hidden: true,
697803
onSelect: (dialog) => {
698-
scroll.scrollTo(scroll.scrollHeight)
804+
const page = paging()
805+
if (page?.hasNewer && !page.loading) {
806+
sync.session.jumpToLatest(route.sessionID).then(() => {
807+
requestAnimationFrame(() => {
808+
if (!scroll || scroll.isDestroyed) return
809+
scroll.scrollTo(scroll.scrollHeight)
810+
refreshEdges()
811+
})
812+
})
813+
} else {
814+
if (!scroll || scroll.isDestroyed) {
815+
dialog.clear()
816+
return
817+
}
818+
scroll.scrollTo(scroll.scrollHeight)
819+
refreshEdges()
820+
}
699821
dialog.clear()
700822
},
701823
},
@@ -725,7 +847,10 @@ export function Session() {
725847
const child = scroll.getChildren().find((child) => {
726848
return child.id === message.id
727849
})
728-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
850+
if (child) {
851+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
852+
refreshEdges()
853+
}
729854
break
730855
}
731856
}
@@ -996,8 +1121,45 @@ export function Session() {
9961121
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
9971122
<Header />
9981123
</Show>
1124+
<Show when={paging()?.loading && paging()?.loadingDirection === "older"}>
1125+
<box flexShrink={0} paddingLeft={1}>
1126+
<text fg={theme.textMuted}>Loading older messages...</text>
1127+
</box>
1128+
</Show>
1129+
<Show when={!paging()?.loading && paging()?.hasOlder && nearTop()}>
1130+
<box flexShrink={0} paddingLeft={1}>
1131+
<text fg={theme.textMuted}>(scroll up for more)</text>
1132+
</box>
1133+
</Show>
1134+
<Show when={paging()?.error}>
1135+
<box flexShrink={0} paddingLeft={1}>
1136+
<text fg={theme.error}>Failed to load: {paging()?.error}</text>
1137+
<text fg={theme.textMuted}> (scroll to retry)</text>
1138+
</box>
1139+
</Show>
9991140
<scrollbox
10001141
ref={(r) => (scroll = r)}
1142+
onMouseScroll={() => {
1143+
refreshEdges()
1144+
loadOlder()
1145+
loadNewer()
1146+
}}
1147+
onKeyDown={(e) => {
1148+
// Standard scroll triggers incremental load
1149+
if (["up", "pageup", "home"].includes(e.name)) {
1150+
setTimeout(() => {
1151+
refreshEdges()
1152+
loadOlder()
1153+
}, 0)
1154+
}
1155+
if (["down", "pagedown", "end"].includes(e.name)) {
1156+
setTimeout(() => {
1157+
refreshEdges()
1158+
loadNewer()
1159+
}, 0)
1160+
}
1161+
}}
1162+
viewportCulling={true}
10011163
viewportOptions={{
10021164
paddingRight: showScrollbar() ? 1 : 0,
10031165
}}
@@ -1110,6 +1272,16 @@ export function Session() {
11101272
)}
11111273
</For>
11121274
</scrollbox>
1275+
<Show when={paging()?.loading && paging()?.loadingDirection === "newer"}>
1276+
<box flexShrink={0} paddingLeft={1}>
1277+
<text fg={theme.textMuted}>Loading newer messages...</text>
1278+
</box>
1279+
</Show>
1280+
<Show when={!paging()?.loading && paging()?.hasNewer && nearBottom()}>
1281+
<box flexShrink={0} paddingLeft={1}>
1282+
<text fg={theme.textMuted}>(scroll down for more)</text>
1283+
</box>
1284+
</Show>
11131285
<box flexShrink={0}>
11141286
<Show when={permissions().length > 0}>
11151287
<PermissionPrompt request={permissions()[0]} />

0 commit comments

Comments
 (0)