Compare commits

...

2 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_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), 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 // Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime. // 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 filePath = path.join(paths.state, "session.json")
const state = { const state = {
pending: false, pending: false,
scroll: new Map<string, number>(),
} }
function save() { function save() {
@@ -455,6 +456,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
function prune(sessionID: string) { function prune(sessionID: string) {
batch(() => { batch(() => {
state.scroll.delete(sessionID)
if (sessionStore.pinned.includes(sessionID)) { if (sessionStore.pinned.includes(sessionID)) {
setSessionStore( setSessionStore(
"pinned", "pinned",
@@ -487,6 +489,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
? sessionStore.pinned.filter((x) => x !== sessionID) ? sessionStore.pinned.filter((x) => x !== sessionID)
: [...sessionStore.pinned, sessionID] : [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next) setSessionStore("pinned", next)
if (exists) state.scroll.delete(sessionID)
save() save()
}) })
}, },
@@ -496,6 +499,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session" && route.data.sessionID === target) return if (route.data.type === "session" && route.data.sessionID === target) return
route.navigate({ type: "session", sessionID: target }) 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, createSignal,
For, For,
Match, Match,
on,
onCleanup, onCleanup,
onMount, onMount,
Show, Show,
@@ -82,6 +81,7 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location" import { LocationProvider } from "../../context/location"
import { Flag } from "@opencode-ai/core/flag/flag"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -259,6 +259,11 @@ export function Session() {
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) 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 wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => { const sidebarVisible = createMemo(() => {
@@ -275,6 +280,7 @@ export function Session() {
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
const editor = useEditorContext() const editor = useEditorContext()
const local = useLocal()
createEffect(() => { createEffect(() => {
const sessionID = route.sessionID const sessionID = route.sessionID
@@ -304,7 +310,15 @@ export function Session() {
} }
editor.reconnect(result.data.directory) editor.reconnect(result.data.directory)
await sync.session.sync(sessionID) 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) => { })().catch((error) => {
if (route.sessionID !== sessionID) return if (route.sessionID !== sessionID) return
toast.show({ toast.show({
@@ -335,6 +349,13 @@ export function Session() {
let seeded = false let seeded = false
let scroll: ScrollBoxRenderable 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 let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => { const bind = (r: PromptRef | undefined) => {
prompt = r prompt = r
@@ -404,24 +425,40 @@ export function Session() {
if (!targetID) { if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
updateAwayFromBottom()
dialog.clear() dialog.clear()
return return
} }
const child = scroll.getChildren().find((c) => c.id === targetID) const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1) if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
dialog.clear() 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() { function toBottom() {
setAwayFromBottom(false)
local.session.setScrollPosition(route.sessionID, undefined)
setTimeout(() => { setTimeout(() => {
if (!scroll || scroll.isDestroyed) return if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight) scroll.scrollTo(scroll.scrollHeight)
}, 50) }, 50)
} }
const local = useLocal()
function enterChild(sessionID: string) { function enterChild(sessionID: string) {
navigate({ navigate({
type: "session", type: "session",
@@ -522,6 +559,7 @@ export function Session() {
return child.id === messageID return child.id === messageID
}) })
if (child) scroll.scrollBy(child.y - scroll.y - 1) if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}} }}
sessionID={route.sessionID} sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)} setPrompt={(promptInfo) => prompt?.set(promptInfo)}
@@ -545,6 +583,7 @@ export function Session() {
return child.id === messageID return child.id === messageID
}) })
if (child) scroll.scrollBy(child.y - scroll.y - 1) if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}} }}
sessionID={route.sessionID} sessionID={route.sessionID}
/> />
@@ -742,6 +781,19 @@ export function Session() {
dialog.clear() 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", title: "Page up",
value: "session.page.up", value: "session.page.up",
@@ -749,6 +801,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(-scroll.height / 2) scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -759,6 +812,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(scroll.height / 2) scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -769,6 +823,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(-1) scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -779,6 +834,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(1) scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -789,6 +845,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(-scroll.height / 4) scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -799,6 +856,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollBy(scroll.height / 4) scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -809,6 +867,7 @@ export function Session() {
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollTo(0) scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -818,7 +877,7 @@ export function Session() {
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => { run: () => {
scroll.scrollTo(scroll.scrollHeight) toBottom()
dialog.clear() dialog.clear()
}, },
}, },
@@ -848,6 +907,7 @@ export function Session() {
return child.id === message.id return child.id === message.id
}) })
if (child) scroll.scrollBy(child.y - scroll.y - 1) if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
break break
} }
} }
@@ -1139,9 +1199,6 @@ export function Session() {
} }
}) })
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
return ( return (
<LocationProvider location={location()}> <LocationProvider location={location()}>
<context.Provider <context.Provider
@@ -1182,6 +1239,7 @@ export function Session() {
stickyStart="bottom" stickyStart="bottom"
flexGrow={1} flexGrow={1}
scrollAcceleration={scrollAcceleration()} scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
> >
<box height={1} /> <box height={1} />
<For each={messages()}> <For each={messages()}>
@@ -1280,6 +1338,18 @@ export function Session() {
</For> </For>
</scrollbox> </scrollbox>
<box flexShrink={0}> <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}> <Show when={permissions().length > 0}>
<PermissionPrompt <PermissionPrompt
request={permissions()[0]} request={permissions()[0]}