Skip to content

Commit 1c805dd

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 a8b2882 commit 1c805dd

16 files changed

Lines changed: 1681 additions & 283 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
1616
const config = useTuiConfig()
1717
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
1818
return pipe(
19-
(config.keybinds ?? {}) as Record<string, string>,
20-
mapValues((value) => Keybind.parse(value)),
19+
config.keybinds ?? {},
20+
mapValues((value) => (typeof value === "string" ? Keybind.parse(value) : [])),
2121
)
2222
})
2323
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
@@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
8181
import { formatTranscript } from "../../util/transcript"
8282
import { UI } from "@/cli/ui.ts"
8383
import { useTuiConfig } from "../../context/tui-config"
84+
import { edgeHints, olderScrollTarget, queueBoundaryLoad } from "@tui/util/pagination"
8485

8586
addDefaultParsers(parsers.parsers)
8687

@@ -129,6 +130,7 @@ export function Session() {
129130
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
130131
})
131132
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
133+
const paging = createMemo(() => sync.data.message_page[route.sessionID])
132134
const permissions = createMemo(() => {
133135
if (session()?.parentID) return []
134136
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -138,6 +140,67 @@ export function Session() {
138140
return children().flatMap((x) => sync.data.question[x.id] ?? [])
139141
})
140142

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

163229
const wide = createMemo(() => dimensions().width > 120)
164230
const sidebarVisible = createMemo(() => {
@@ -192,7 +258,9 @@ export function Session() {
192258
await sync.session
193259
.sync(route.sessionID)
194260
.then(() => {
195-
if (scroll) scroll.scrollBy(100_000)
261+
if (!scroll || scroll.isDestroyed) return
262+
scroll.scrollBy(100_000)
263+
refreshEdges()
196264
})
197265
.catch((e) => {
198266
console.error(e)
@@ -204,6 +272,16 @@ export function Session() {
204272
})
205273
})
206274

275+
createEffect(() => {
276+
if (!scroll || scroll.isDestroyed) return
277+
messages()
278+
queueMicrotask(() => {
279+
requestAnimationFrame(() => {
280+
refreshEdges()
281+
})
282+
})
283+
})
284+
207285
const toast = useToast()
208286
const sdk = useSDK()
209287

@@ -270,7 +348,7 @@ export function Session() {
270348
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
271349
const children = scroll.getChildren()
272350
const messagesList = messages()
273-
const scrollTop = scroll.y
351+
const scrollTop = scroll.scrollTop
274352

275353
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
276354
const visibleMessages = children
@@ -302,20 +380,26 @@ export function Session() {
302380
const targetID = findNextVisibleMessage(direction)
303381

304382
if (!targetID) {
305-
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
383+
scrollMove(direction === "next" ? scroll.height : -scroll.height)
306384
dialog.clear()
307385
return
308386
}
309387

310388
const child = scroll.getChildren().find((c) => c.id === targetID)
311-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
389+
if (child) {
390+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
391+
refreshEdges()
392+
}
312393
dialog.clear()
313394
}
314395

315396
function toBottom() {
316397
setTimeout(() => {
317398
if (!scroll || scroll.isDestroyed) return
318399
scroll.scrollTo(scroll.scrollHeight)
400+
requestAnimationFrame(() => {
401+
refreshEdges()
402+
})
319403
}, 50)
320404
}
321405

@@ -419,7 +503,10 @@ export function Session() {
419503
const child = scroll.getChildren().find((child) => {
420504
return child.id === messageID
421505
})
422-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
506+
if (child) {
507+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
508+
refreshEdges()
509+
}
423510
}}
424511
sessionID={route.sessionID}
425512
setPrompt={(promptInfo) => prompt.set(promptInfo)}
@@ -442,7 +529,10 @@ export function Session() {
442529
const child = scroll.getChildren().find((child) => {
443530
return child.id === messageID
444531
})
445-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
532+
if (child) {
533+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
534+
refreshEdges()
535+
}
446536
}}
447537
sessionID={route.sessionID}
448538
/>
@@ -661,7 +751,7 @@ export function Session() {
661751
category: "Session",
662752
hidden: true,
663753
onSelect: (dialog) => {
664-
scroll.scrollBy(-scroll.height / 2)
754+
scrollMove(-scroll.height / 2)
665755
dialog.clear()
666756
},
667757
},
@@ -672,7 +762,7 @@ export function Session() {
672762
category: "Session",
673763
hidden: true,
674764
onSelect: (dialog) => {
675-
scroll.scrollBy(scroll.height / 2)
765+
scrollMove(scroll.height / 2)
676766
dialog.clear()
677767
},
678768
},
@@ -683,7 +773,7 @@ export function Session() {
683773
category: "Session",
684774
disabled: true,
685775
onSelect: (dialog) => {
686-
scroll.scrollBy(-1)
776+
scrollMove(-1)
687777
dialog.clear()
688778
},
689779
},
@@ -694,7 +784,7 @@ export function Session() {
694784
category: "Session",
695785
disabled: true,
696786
onSelect: (dialog) => {
697-
scroll.scrollBy(1)
787+
scrollMove(1)
698788
dialog.clear()
699789
},
700790
},
@@ -705,7 +795,7 @@ export function Session() {
705795
category: "Session",
706796
hidden: true,
707797
onSelect: (dialog) => {
708-
scroll.scrollBy(-scroll.height / 4)
798+
scrollMove(-scroll.height / 4)
709799
dialog.clear()
710800
},
711801
},
@@ -716,7 +806,7 @@ export function Session() {
716806
category: "Session",
717807
hidden: true,
718808
onSelect: (dialog) => {
719-
scroll.scrollBy(scroll.height / 4)
809+
scrollMove(scroll.height / 4)
720810
dialog.clear()
721811
},
722812
},
@@ -727,7 +817,23 @@ export function Session() {
727817
category: "Session",
728818
hidden: true,
729819
onSelect: (dialog) => {
730-
scroll.scrollTo(0)
820+
const page = paging()
821+
if (page?.hasOlder && !page.loading) {
822+
sync.session.jumpToOldest(route.sessionID).then(() => {
823+
requestAnimationFrame(() => {
824+
if (!scroll || scroll.isDestroyed) return
825+
scroll.scrollTo(0)
826+
refreshEdges()
827+
})
828+
})
829+
} else {
830+
if (!scroll || scroll.isDestroyed) {
831+
dialog.clear()
832+
return
833+
}
834+
scroll.scrollTo(0)
835+
refreshEdges()
836+
}
731837
dialog.clear()
732838
},
733839
},
@@ -738,7 +844,23 @@ export function Session() {
738844
category: "Session",
739845
hidden: true,
740846
onSelect: (dialog) => {
741-
scroll.scrollTo(scroll.scrollHeight)
847+
const page = paging()
848+
if (page?.hasNewer && !page.loading) {
849+
sync.session.jumpToLatest(route.sessionID).then(() => {
850+
requestAnimationFrame(() => {
851+
if (!scroll || scroll.isDestroyed) return
852+
scroll.scrollTo(scroll.scrollHeight)
853+
refreshEdges()
854+
})
855+
})
856+
} else {
857+
if (!scroll || scroll.isDestroyed) {
858+
dialog.clear()
859+
return
860+
}
861+
scroll.scrollTo(scroll.scrollHeight)
862+
refreshEdges()
863+
}
742864
dialog.clear()
743865
},
744866
},
@@ -768,7 +890,10 @@ export function Session() {
768890
const child = scroll.getChildren().find((child) => {
769891
return child.id === message.id
770892
})
771-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
893+
if (child) {
894+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
895+
refreshEdges()
896+
}
772897
break
773898
}
774899
}
@@ -1051,8 +1176,45 @@ export function Session() {
10511176
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
10521177
<Header />
10531178
</Show>
1179+
<Show when={paging()?.loading && paging()?.loadingDirection === "older"}>
1180+
<box flexShrink={0} paddingLeft={1}>
1181+
<text fg={theme.textMuted}>Loading older messages...</text>
1182+
</box>
1183+
</Show>
1184+
<Show when={!paging()?.loading && paging()?.hasOlder && nearTop()}>
1185+
<box flexShrink={0} paddingLeft={1}>
1186+
<text fg={theme.textMuted}>(scroll up for more)</text>
1187+
</box>
1188+
</Show>
1189+
<Show when={paging()?.error}>
1190+
<box flexShrink={0} paddingLeft={1}>
1191+
<text fg={theme.error}>Failed to load: {paging()?.error}</text>
1192+
<text fg={theme.textMuted}> (scroll to retry)</text>
1193+
</box>
1194+
</Show>
10541195
<scrollbox
10551196
ref={(r) => (scroll = r)}
1197+
onMouseScroll={() => {
1198+
refreshEdges()
1199+
loadOlder()
1200+
loadNewer()
1201+
}}
1202+
onKeyDown={(e) => {
1203+
// Standard scroll triggers incremental load
1204+
if (["up", "pageup", "home"].includes(e.name)) {
1205+
setTimeout(() => {
1206+
refreshEdges()
1207+
loadOlder()
1208+
}, 0)
1209+
}
1210+
if (["down", "pagedown", "end"].includes(e.name)) {
1211+
setTimeout(() => {
1212+
refreshEdges()
1213+
loadNewer()
1214+
}, 0)
1215+
}
1216+
}}
1217+
viewportCulling={true}
10561218
viewportOptions={{
10571219
paddingRight: showScrollbar() ? 1 : 0,
10581220
}}
@@ -1165,6 +1327,16 @@ export function Session() {
11651327
)}
11661328
</For>
11671329
</scrollbox>
1330+
<Show when={paging()?.loading && paging()?.loadingDirection === "newer"}>
1331+
<box flexShrink={0} paddingLeft={1}>
1332+
<text fg={theme.textMuted}>Loading newer messages...</text>
1333+
</box>
1334+
</Show>
1335+
<Show when={!paging()?.loading && paging()?.hasNewer && nearBottom()}>
1336+
<box flexShrink={0} paddingLeft={1}>
1337+
<text fg={theme.textMuted}>(scroll down for more)</text>
1338+
</box>
1339+
</Show>
11681340
<box flexShrink={0}>
11691341
<Show when={permissions().length > 0}>
11701342
<PermissionPrompt request={permissions()[0]} />

0 commit comments

Comments
 (0)