Compare commits

...
Author SHA1 Message Date
Kit Langton cfc82d6dcb feat(tui): prototype tab scroll controls 2026-08-13 12:58:43 -04:00
Kit Langton d63db95c07 fix(tui): remember session scroll position 2026-08-13 12:50:09 -04:00
3 changed files with 93 additions and 8 deletions
+1
View File
@@ -48,6 +48,7 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_TAB_SCROLL: enabledByExperimental("OPENCODE_EXPERIMENTAL_TAB_SCROLL"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+14
View File
@@ -417,6 +417,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const filePath = path.join(paths.state, "session.json")
const state = {
pending: false,
scroll: new Map<string, number>(),
}
function save() {
@@ -455,6 +456,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
function prune(sessionID: string) {
batch(() => {
state.scroll.delete(sessionID)
if (sessionStore.pinned.includes(sessionID)) {
setSessionStore(
"pinned",
@@ -487,6 +489,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
? sessionStore.pinned.filter((x) => x !== sessionID)
: [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next)
if (exists) state.scroll.delete(sessionID)
save()
})
},
@@ -496,6 +499,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session" && route.data.sessionID === target) return
route.navigate({ type: "session", sessionID: target })
},
scrollPosition(sessionID: string) {
if (!slots().includes(sessionID)) return
return state.scroll.get(sessionID)
},
setScrollPosition(sessionID: string, position: number | undefined) {
if (position === undefined || !slots().includes(sessionID)) {
state.scroll.delete(sessionID)
return
}
state.scroll.set(sessionID, position)
},
}
}
+78 -8
View File
@@ -6,7 +6,6 @@ import {
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
@@ -82,6 +81,7 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { Flag } from "@opencode-ai/core/flag/flag"
addDefaultParsers(parsers.parsers)
@@ -259,6 +259,11 @@ export function Session() {
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
const [jumpBottomPosition, setJumpBottomPosition] = kv.signal<"center" | "right">(
"experimental_jump_bottom_position",
"center",
)
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -275,6 +280,7 @@ export function Session() {
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
const local = useLocal()
createEffect(() => {
const sessionID = route.sessionID
@@ -304,7 +310,15 @@ export function Session() {
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setTimeout(() => {
if (route.sessionID !== sessionID || !scroll || scroll.isDestroyed) return
scroll.scrollTo(
Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL
? (local.session.scrollPosition(sessionID) ?? scroll.scrollHeight)
: scroll.scrollHeight,
)
updateAwayFromBottom()
}, 50)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
@@ -335,6 +349,13 @@ export function Session() {
let seeded = false
let scroll: ScrollBoxRenderable
onCleanup(() => {
if (!scroll || scroll.isDestroyed) return
local.session.setScrollPosition(
route.sessionID,
Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL && isAwayFromBottom() ? scroll.scrollTop : undefined,
)
})
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
@@ -404,24 +425,40 @@ export function Session() {
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
updateAwayFromBottom()
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
dialog.clear()
}
function isAwayFromBottom() {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (!Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL) return
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
const away = isAwayFromBottom()
setAwayFromBottom(away)
if (!away) local.session.setScrollPosition(route.sessionID, undefined)
})
}
function toBottom() {
setAwayFromBottom(false)
local.session.setScrollPosition(route.sessionID, undefined)
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
}, 50)
}
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
@@ -522,6 +559,7 @@ export function Session() {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
@@ -545,6 +583,7 @@ export function Session() {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}}
sessionID={route.sessionID}
/>
@@ -742,6 +781,19 @@ export function Session() {
dialog.clear()
},
},
{
title: `Move jump-to-bottom button ${jumpBottomPosition() === "center" ? "right" : "to center"}`,
value: "session.jump_bottom.position",
category: "Session",
hidden: !Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL,
slash: {
name: "jump-bottom-position",
},
run: () => {
setJumpBottomPosition((position) => (position === "center" ? "right" : "center"))
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
@@ -749,6 +801,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -759,6 +812,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -769,6 +823,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -779,6 +834,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -789,6 +845,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -799,6 +856,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -809,6 +867,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -818,7 +877,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(scroll.scrollHeight)
toBottom()
dialog.clear()
},
},
@@ -848,6 +907,7 @@ export function Session() {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
break
}
}
@@ -1139,9 +1199,6 @@ export function Session() {
}
})
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
return (
<LocationProvider location={location()}>
<context.Provider
@@ -1182,6 +1239,7 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
>
<box height={1} />
<For each={messages()}>
@@ -1280,6 +1338,18 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL && awayFromBottom()}>
<box
height={1}
flexDirection="row"
justifyContent={jumpBottomPosition() === "center" ? "center" : "flex-end"}
paddingRight={jumpBottomPosition() === "right" ? 1 : 0}
>
<text fg={theme.textMuted} onMouseUp={toBottom}>
Bottom
</text>
</box>
</Show>
<Show when={permissions().length > 0}>
<PermissionPrompt
request={permissions()[0]}