mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5773bec988 | |||
| 1ba02c3864 | |||
| f21ca644a4 | |||
| e606521be1 | |||
| 4113929128 | |||
| 2f883a7ba6 | |||
| 48c3197524 | |||
| abb72aa2f2 | |||
| 28a5e32156 | |||
| 4d502dd98a |
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -94,7 +94,6 @@ export function Composer(props: ComposerProps) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => props.open,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
|
||||
@@ -50,7 +50,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("shell"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.shell.up",
|
||||
|
||||
@@ -164,7 +164,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("subagents"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.subagent.up",
|
||||
|
||||
@@ -115,9 +115,12 @@ addDefaultParsers(parsers.parsers)
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||
|
||||
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
|
||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||
// in a few hundred milliseconds without a perceptible pause.
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
@@ -271,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)
|
||||
@@ -316,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
|
||||
@@ -332,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)
|
||||
@@ -351,32 +362,32 @@ export function Session() {
|
||||
})
|
||||
}
|
||||
|
||||
// Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows
|
||||
// mount on demand near the top, keeping inactive tabs cheap to tear down. Until the first chunk
|
||||
// pins the count, the hidden span derives from the row count, so streaming appends remain visible.
|
||||
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
|
||||
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
|
||||
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
|
||||
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
|
||||
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
|
||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
let revealingOlderRows = false
|
||||
const revealOlderRows = (scrollBy = 0) => {
|
||||
createEffect(() => {
|
||||
const current = hidden()
|
||||
if (
|
||||
revealingOlderRows ||
|
||||
current === 0 ||
|
||||
!scroll ||
|
||||
scroll.isDestroyed ||
|
||||
scroll.scrollTop > scroll.viewport.height
|
||||
)
|
||||
return false
|
||||
revealingOlderRows = true
|
||||
const before = scroll.scrollHeight
|
||||
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
|
||||
afterLayout(() => {
|
||||
revealingOlderRows = false
|
||||
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (current === 0) return
|
||||
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
|
||||
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
|
||||
// cadence undisturbed by appends.
|
||||
const timer = setTimeout(() => {
|
||||
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
|
||||
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
|
||||
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
|
||||
if (before === undefined) return
|
||||
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
|
||||
// readers who have scrolled up.
|
||||
if (viewportBottom >= before - 1) return
|
||||
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
|
||||
}, TRANSCRIPT_BACKFILL_DELAY)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
||||
const ensureAllRows = (continuation: () => void) => {
|
||||
if (hidden() === 0) return continuation()
|
||||
@@ -384,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
|
||||
@@ -492,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)
|
||||
@@ -506,7 +544,8 @@ export function Session() {
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
if (!revealOlderRows(-scroll.height / 2)) scroll.scrollBy(-scroll.height / 2)
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -518,6 +557,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 2)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -528,7 +568,8 @@ export function Session() {
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
if (!revealOlderRows(-1)) scroll.scrollBy(-1)
|
||||
scroll.scrollBy(-1)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -540,6 +581,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(1)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -550,7 +592,8 @@ export function Session() {
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
if (!revealOlderRows(-scroll.height / 4)) scroll.scrollBy(-scroll.height / 4)
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -562,6 +605,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 4)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -575,7 +619,8 @@ export function Session() {
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
ensureAllRows(() => scroll.scrollTo(0))
|
||||
scroll.scrollTo(0)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -585,8 +630,7 @@ export function Session() {
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
toBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1003,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,
|
||||
@@ -1040,50 +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)}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up") revealOlderRows()
|
||||
}}
|
||||
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} />
|
||||
|
||||
@@ -23,11 +23,7 @@ const sessions = {
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
|
||||
async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
) {
|
||||
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
@@ -73,21 +69,7 @@ async function renderComposer(
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppExit() {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||
return null
|
||||
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
@@ -106,7 +88,6 @@ async function renderComposer(
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
@@ -173,20 +154,6 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("configured composer bindings work with a focused textarea", async () => {
|
||||
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
|
||||
try {
|
||||
composer.app.mockInput.pressArrow("right")
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressKey("u", { ctrl: true })
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user