Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 8b50212f78 docs(go): use responses endpoint for Grok 4.5 2026-08-13 17:03:51 +00:00
11 changed files with 61 additions and 246 deletions
+1 -2
View File
@@ -115,10 +115,9 @@ export function elements(renderer: CliRenderer): Element[] {
}
export function state(harness: Harness) {
const renderable = harness.renderer.currentFocusedRenderable?.num
return {
focused: {
...(renderable === undefined ? {} : { renderable }),
renderable: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor),
},
elements: elements(harness.renderer),
-12
View File
@@ -14,18 +14,6 @@ test("matches literal screen text", () => {
expect(matches(harness, "opencode")).toBe(false)
})
test("omits an absent focused renderable from state", () => {
const harness = {
renderer: {
root: { getChildren: () => [] },
currentFocusedRenderable: undefined,
currentFocusedEditor: undefined,
},
} as unknown as Harness
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
})
test("normalizes named keys for OpenTUI", async () => {
const pressed: Array<readonly [string, object | undefined]> = []
const harness = {
@@ -13,13 +13,7 @@ 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[] = [
{
id: "tab_scroll",
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
},
]
export const experiments: Experiment[] = []
export function DialogExperiments() {
const config = useConfig()
@@ -33,6 +27,7 @@ export function DialogExperiments() {
const options = createMemo(() =>
experiments.map((experiment) => ({
title: experiment.title,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
+3 -22
View File
@@ -21,7 +21,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -409,20 +408,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
const location = value ? data.location.info(value.location) : undefined
const worktree = !!location && location.project.directory !== location.project.canonical
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -464,10 +453,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) =>
detailFades()
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
: detailColor()
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -685,11 +670,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
+3 -6
View File
@@ -94,7 +94,7 @@ type Store = {
location: Record<string, LocationData>
}
export function locationKey(location: LocationRef) {
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
default() {
return defaultLocation()
},
syncInfo(ref?: LocationRef) {
async sync(ref?: LocationRef) {
const current = ref ?? defaultLocation()
return sync.run(`location:${locationKey(current)}`, async () => {
await sync.run(`location:${locationKey(current)}`, async () => {
const location = await client.api.location.get({ location: locationQuery(current) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
@@ -1225,9 +1225,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
}
})
},
async sync(ref?: LocationRef) {
await result.location.syncInfo(ref)
const location = ref ?? defaultLocation()
await Promise.all([
result.location.vcs.sync(location),
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabDetail(
project: string,
current: string | undefined,
defaultBranch: string | undefined,
worktree: boolean,
) {
const branch = worktree && current !== defaultBranch ? current : undefined
return branch && project ? `${project}${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+7 -42
View File
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { locationKey, useData } from "./data"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useEvent } from "./event"
import { useRoute } from "./route"
@@ -66,12 +66,6 @@ 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
@@ -165,9 +159,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -177,25 +171,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (client.connection.status() !== "connected") return
const signature = openTabSessions()
if (signature === "") return
const sessionIDs = signature.split("\n")
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [locationKey(location), location]),
)
await Promise.allSettled(
Array.from(locations.values(), (location) =>
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
),
)
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
@@ -237,7 +216,6 @@ 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
@@ -269,19 +247,6 @@ 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) })
+42 -90
View File
@@ -274,7 +274,6 @@ 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)
@@ -320,7 +319,7 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -336,13 +335,6 @@ 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)
@@ -395,31 +387,6 @@ 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
@@ -528,8 +495,6 @@ export function Session() {
function toBottom() {
clearMessageNavigation()
setAwayFromBottom(false)
sessionTabs.setScrollPosition(route.sessionID, undefined)
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -545,7 +510,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -557,7 +521,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -569,7 +532,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -581,7 +543,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -593,7 +554,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -605,7 +565,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -620,7 +579,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -630,7 +588,8 @@ export function Session() {
group: "Session",
palette: undefined,
run: () => {
toBottom()
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
},
@@ -1047,6 +1006,8 @@ 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,
@@ -1082,56 +1043,47 @@ export function Session() {
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<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 ?? []}
<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()]}
/>
</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>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
/>
</Show>
</box>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
@@ -11,20 +11,11 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("appends the branch to the project detail", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -28,14 +28,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -52,25 +45,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
return json({
directory: requested,
project: { id: "project", directory: requested, canonical: directory },
})
}
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -80,7 +55,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -132,8 +107,6 @@ async function renderSessionTabs(
route,
data,
sessions,
locations,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -164,22 +137,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.locations.includes(other))
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
+1 -1
View File
@@ -197,7 +197,7 @@ You can also access Go models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package |
| ----------------- | ----------------- | ------------------------------------------------ | --------------------------- |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |