Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton 5773bec988 fix(tui): preserve transcript action spacing 2026-08-13 14:32:25 -04:00
Kit Langton 1ba02c3864 fix(tui): raise latest action overlay 2026-08-13 13:58:54 -04:00
Kit Langton f21ca644a4 fix(tui): size latest action overlay 2026-08-13 13:58:00 -04:00
Kit Langton e606521be1 fix(tui): anchor latest action over transcript 2026-08-13 13:57:04 -04:00
Kit Langton 4113929128 fix(tui): reveal latest action overlay 2026-08-13 13:55:42 -04:00
Kit Langton 2f883a7ba6 fix(tui): overlay latest transcript action 2026-08-13 13:54:25 -04:00
Kit Langton 48c3197524 refactor(tui): align latest transcript action 2026-08-13 13:53:40 -04:00
Kit Langton abb72aa2f2 refactor(tui): label latest transcript action 2026-08-13 13:50:43 -04:00
Kit Langton 28a5e32156 refactor(tui): simplify experiments dialog 2026-08-13 13:27:14 -04:00
Kit Langton 4d502dd98a feat(tui): prototype tab scroll memory 2026-08-13 13:27:14 -04:00
3 changed files with 117 additions and 44 deletions
@@ -13,7 +13,13 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "tab_scroll",
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
},
]
export function DialogExperiments() {
const config = useConfig()
@@ -27,7 +33,6 @@ export function DialogExperiments() {
const options = createMemo(() =>
experiments.map((experiment) => ({
title: experiment.title,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
+20
View File
@@ -66,6 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
const scrollPositions = new Map<string, number>()
createEffect(() => {
if (config.experimental?.tab_scroll === true) return
scrollPositions.clear()
})
function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
@@ -231,6 +237,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
scrollPositions.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
if (closed.tabs === state().tabs && !selected) return
@@ -262,6 +269,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
current,
status,
scrollPosition(sessionID: string) {
const target = root(sessionID)
if (!state().tabs.some((tab) => tab.sessionID === target)) return
return scrollPositions.get(target)
},
setScrollPosition(sessionID: string, position: number | undefined) {
const target = root(sessionID)
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
scrollPositions.delete(target)
return
}
scrollPositions.set(target, position)
},
select(sessionID: string) {
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
+90 -42
View File
@@ -274,6 +274,7 @@ export function Session() {
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const clearMessageNavigation = () => {
setNavigationSlack(0)
@@ -319,7 +320,7 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -335,6 +336,13 @@ export function Session() {
let seeded = false
let sent = false
let scroll: ScrollBoxRenderable
onCleanup(() => {
if (!scroll || scroll.isDestroyed) return
sessionTabs.setScrollPosition(
route.sessionID,
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
)
})
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
setPrompt(r)
@@ -387,6 +395,31 @@ export function Session() {
afterLayout(continuation)
}
function isAwayFromBottom() {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (config.experimental?.tab_scroll !== true) return
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
const away = isAwayFromBottom()
setAwayFromBottom(away)
if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined)
})
}
function restoreScrollPosition(sessionID: string) {
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
if (position === undefined) {
scroll.scrollTo(scroll.scrollHeight)
setAwayFromBottom(false)
return
}
ensureAllRows(() => {
scroll.scrollTo(position)
updateAwayFromBottom()
})
}
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
@@ -495,6 +528,8 @@ export function Session() {
function toBottom() {
clearMessageNavigation()
setAwayFromBottom(false)
sessionTabs.setScrollPosition(route.sessionID, undefined)
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -510,6 +545,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -521,6 +557,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -532,6 +569,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -543,6 +581,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -554,6 +593,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -565,6 +605,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -579,6 +620,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -588,8 +630,7 @@ export function Session() {
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
toBottom()
dialog.clear()
},
},
@@ -1006,8 +1047,6 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
createEffect(
on(
() => route.sessionID,
@@ -1043,47 +1082,56 @@ export function Session() {
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
<box flexGrow={1} minHeight={0} position="relative">
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
<text fg={theme.text.subdued} onMouseUp={toBottom}>
Latest
</text>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />