+
+
-
-
-
-
-
{
- const stop = createFileTabListSync({ el, contextOpen })
- onCleanup(stop)
- }}
+
-
-
-
-
{language.t("session.tab.review")}
-
- {props.reviewCount()}
-
-
-
-
-
-
- tabs().close("context")}
- aria-label={language.t("common.closeTab")}
- />
-
- }
- hideCloseButton
- onMiddleClick={() => tabs().close("context")}
- >
-
-
-
{language.t("session.tab.context")}
-
-
-
-
- {(tab) => }
-
-
-
- {
- void import("@/components/dialog-select-file").then((x) => {
- dialog.show(() => )
- })
+
+
+
+
+ {
+ const stop = createFileTabListSync({ el, contextOpen })
+ onCleanup(stop)
}}
- aria-label={language.t("command.file.open")}
- />
-
-
-
-
+ >
+
+
+
+
{language.t("session.tab.review")}
+
+ {props.reviewCount()}
+
+
+
+
+
+
+ tabs().close("context")}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close("context")}
+ >
+
+
+
{language.t("session.tab.context")}
+
+
+
+
+
+ {(tab) => (
+
+ }
+ >
+
+ tabs().close(SESSION_OPEN_FILE_TAB)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
+ >
+
+
+ {language.t("command.file.open")}
+
+
+
+ )}
+
+
+
+
+ {
+ void import("@/components/dialog-select-file").then((x) => {
+ dialog.show(() => )
+ })
+ }}
+ aria-label={language.t("command.file.open")}
+ />
+
+
+
+
-
-
- {props.reviewPanel()}
-
-
-
-
-
-
-
-
-
- {language.t("session.files.selectToOpen")}
+
+
+ {props.reviewPanel()}
+
+
+
+
+
+
+
+
+ {language.t("session.files.selectToOpen")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(tab) => }
+
+
+
+
+ {(tab) => {
+ const path = file.pathFromTab(tab)
+ return (
+
+
+ {(p) => }
+
+
+ )
+ }}
+
+
+
+ }
+ >
+
+ event.target instanceof Element &&
+ (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
+ !!event.target.closest(".session-review-v2-open-in-app-slot")),
+ }),
+ ]}
+ modifiers={[
+ RestrictToHorizontalAxis,
+ RestrictToElement.configure({ element: () => tabList ?? null }),
+ ]}
+ plugins={(defaults) => [
+ ...defaults.filter((plugin) => plugin !== Accessibility),
+ AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
+ Feedback.configure({ dropAnimation: null }),
+ ]}
+ onDragEnd={(event) => {
+ const source = event.operation.source
+ if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
+ tabs().move(source.id.toString(), source.index)
+ }}
+ >
+
+
+
{
+ tabList = el
+ const stop = createFileTabListSync({ el, contextOpen })
+ onCleanup(stop)
+ }}
+ >
+
+ {(toggle) => (
+
+ )}
+
+
+
+ {props.hasReview()
+ ? language.t("session.review.filesChanged", { count: props.reviewCount() })
+ : language.t("session.tab.review")}
+
+
+
+
+ {language.t("common.closeTab")}
+ 0}>
+
+
+ >
+ }
+ placement="bottom"
+ gutter={10}
+ >
+ tabs().close("context")}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close("context")}
+ >
+
+
+
{language.t("session.tab.context")}
+
+
+
+
+ {(tab) => (
+ tabs().all().indexOf(tab)}
+ temporary={temporaryTab() === tab}
+ onTabClose={tabs().close}
+ onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
+ />
+ }
+ >
+
+ {language.t("common.closeTab")}
+ 0}>
+
+
+ >
+ }
+ placement="bottom"
+ gutter={10}
+ >
+ tabs().close(SESSION_OPEN_FILE_TAB)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
+ >
+
+
+ {language.t("command.file.open")}
+
+
+
+ )}
+
+
+
+ {language.t("command.file.open")}
+ 0}>
+
+
+ >
+ }
+ placement="bottom"
+ class="flex items-center"
+ >
+ }
+ variant="ghost-muted"
+ size="large"
+ onClick={() => openFileBrowser()}
+ aria-label={language.t("command.file.open")}
+ />
+
+
+
+
event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+
-
-
-
-
-
-
-
+
+
+ {props.reviewPanel()}
-
-
-
- {(tab) => }
-
-
-
-
- {(tab) => {
- const path = file.pathFromTab(tab)
- return (
-
-
{(p) => }
+
+
+
+
+
+
+ {language.t("session.files.selectToOpen")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ previewTab(file.tab(path))}
+ onSelectPermanent={(path) => openTab(file.tab(path))}
+ filterRef={(element) => (fileFilter = element)}
+ />
- )
- }}
-
-
-
+
+
+
+
+
-
+
-
+
- {props.reviewCount()}{" "}
- {language.t(
- props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
- )}
+
+ {props.reviewCount()}{" "}
+ {language.t(
+ props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
+ )}
+ >
+ }
+ >
+ {language.t("session.review.filesChanged", { count: props.reviewCount() })}
+
{language.t("session.files.all")}
-
-
-
-
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
-
- }
- >
+
+
+
+
+
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
+
+ }
+ >
+
props.focusReviewDiff(node.path)}
+ />
+
+
+
+
+
+
+
+
+ {empty(language.t("session.files.empty"))}
+
props.focusReviewDiff(node.path)}
+ onFileClick={(node) => openTab(file.tab(node.path))}
/>
-
-
-
-
-
-
- {empty(language.t("session.files.empty"))}
-
- openTab(file.tab(node.path))}
- />
-
-
-
+
+
+
+
diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx
new file mode 100644
index 0000000000..6a92e24ea6
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-panel-v2.tsx
@@ -0,0 +1,352 @@
+import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createMediaQuery } from "@solid-primitives/media"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable } from "@dnd-kit/solid/sortable"
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+
+import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
+import { Terminal } from "@/components/terminal"
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSettings } from "@/context/settings"
+import { useTerminal } from "@/context/terminal"
+import { useSDK } from "@/context/sdk"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+import { createSizing, focusTerminalById } from "@/pages/session/helpers"
+import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
+import { useSessionLayout } from "@/pages/session/session-layout"
+
+export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
+ const layout = useLayout()
+ const terminal = useTerminal()
+ const sdk = useSDK()
+ const language = useLanguage()
+ const command = useCommand()
+ const settings = useSettings()
+ const { workspaceKey, view } = useSessionLayout()
+
+ const isDesktop = createMediaQuery("(min-width: 768px)")
+ const newLayout = createMemo(() => settings.general.newLayoutDesigns())
+ const opened = createMemo(() => view().terminal.opened())
+ const size = createSizing()
+ const height = createMemo(() => layout.terminal.height())
+ const close = () => view().terminal.close()
+ let root: HTMLDivElement | undefined
+ let tabList: HTMLDivElement | undefined
+
+ onCleanup(() => terminal.cancelFocus())
+
+ const [store, setStore] = createStore({
+ autoCreated: false,
+ recovered: {} as Record,
+ view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
+ })
+
+ const max = () => store.view * 0.6
+ const pane = () => Math.min(height(), max())
+ const stacked = createMemo(() => isDesktop() && props.stacked)
+ const panelHeight = createMemo(() =>
+ isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
+ )
+ const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
+ const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
+
+ onMount(() => {
+ if (typeof window === "undefined") return
+
+ const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
+ const port = window.visualViewport
+
+ sync()
+ makeEventListener(window, "resize", sync)
+ if (port) makeEventListener(port, "resize", sync)
+ })
+
+ createEffect(() => {
+ if (!opened()) {
+ setStore("autoCreated", false)
+ return
+ }
+
+ if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
+ terminal.new()
+ setStore("autoCreated", true)
+ })
+
+ createEffect(
+ on(
+ () => terminal.all().length,
+ (count, prevCount) => {
+ if (prevCount === undefined || prevCount <= 0 || count !== 0) return
+ if (!opened()) return
+ close()
+ },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
+ ([next, id, requested]) => {
+ if (!next || !id || !requested) return
+ focusTerminalById(id)
+ },
+ ),
+ )
+
+ createEffect(() => {
+ if (opened()) return
+ const active = document.activeElement
+ if (!(active instanceof HTMLElement)) return
+ if (!root?.contains(active)) return
+ active.blur()
+ })
+
+ createEffect(() => {
+ const dir = sdk().directory
+ if (!dir) return
+ if (!terminal.ready()) return
+ language.locale()
+
+ setTerminalHandoff(
+ workspaceKey(),
+ terminal.all().map((pty) =>
+ terminalTabLabel({
+ title: pty.title,
+ titleNumber: pty.titleNumber,
+ t: language.t as (key: string, vars?: Record) => string,
+ }),
+ ),
+ )
+ })
+
+ const handoff = createMemo(() => {
+ const dir = sdk().directory
+ if (!dir) return []
+ return getTerminalHandoff(workspaceKey()) ?? []
+ })
+
+ const all = terminal.all
+ const ids = createMemo(() => all().map((pty) => pty.id))
+
+ const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => {
+ if (store.recovered[key]) return
+ setStore("recovered", key, true)
+ void clone(id)
+ }
+
+ const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
+ return String(pty.titleNumber || pty.title || pty.id)
+ }
+
+ const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
+ setStore("recovered", key, false)
+ trim(id)
+ }
+
+ const handleTerminalDragEnd = () => {
+ const activeId = terminal.active()
+ if (!activeId) return
+ requestAnimationFrame(() => {
+ if (terminal.active() !== activeId) return
+ focusTerminalById(activeId)
+ })
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 54195e2ebc..2596eaee83 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -38,6 +38,8 @@ export function TerminalPanel() {
const close = () => view().terminal.close()
let root: HTMLDivElement | undefined
+ onCleanup(() => terminal.cancelFocus())
+
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
@@ -285,7 +287,7 @@ export function TerminalPanel() {
icon="plus-small"
variant="ghost"
iconSize="large"
- onClick={terminal.new}
+ onClick={() => terminal.new()}
aria-label={language.t("command.terminal.new")}
/>
@@ -303,6 +305,7 @@ export function TerminalPanel() {
terminal.consumeFocus(id)}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 9d8d82bcc9..2959e9f8a9 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
+import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
@@ -160,8 +161,10 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
>
- {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "}
- {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+ {language.t(
+ props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
+ { count: String(props.diffs.length) },
+ )}
0}>
@@ -325,6 +328,7 @@ export function MessageTimeline(props: {
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
+ inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -405,6 +409,7 @@ export function MessageTimeline(props: {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
+ observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
@@ -598,8 +603,12 @@ export function MessageTimeline(props: {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
- if (event.target !== event.currentTarget) return
- props.onMarkScrollGesture(event.currentTarget)
+ props.onMarkScrollGesture(event.target)
+ }
+
+ const handleListPointerMove = (event: PointerEvent) => {
+ if (event.buttons !== 1) return
+ props.onMarkScrollGesture(event.target)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
@@ -1129,6 +1138,10 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
+ const messageComments = createMemo(() => {
+ if (!settings.general.newLayoutDesigns()) return []
+ return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
+ })
return (
@@ -1140,6 +1153,7 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
+ comments={messageComments()}
/>
@@ -1237,12 +1251,13 @@ export function MessageTimeline(props: {
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
- const asyncFile = () => {
+ const tool = () => {
const value = row()
- if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
+ if (value._tag !== "AssistantPart" || value.group.type !== "part") return
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
- return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
+ if (part?.type === "tool") return part
}
+ const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1272,6 +1287,8 @@ export function MessageTimeline(props: {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
+ // Rounded virtual measurements can otherwise clip a framed row's outer paint.
+ "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
@@ -15,6 +15,12 @@ describe("timeline model", () => {
expect(selectVisibleUserMessages(users)).toBe(users)
})
+ test("waits for an assistant-only load to hydrate its user root", () => {
+ expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
+ expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
+ expect(isTimelineReady([], false)).toBe(true)
+ })
+
test("loads exactly one opaque cursor page", async () => {
let calls = 0
const anchors: Array = []
diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts
index 91bfc0eaf7..7eebee6080 100644
--- a/packages/app/src/pages/session/timeline/model.ts
+++ b/packages/app/src/pages/session/timeline/model.ts
@@ -45,7 +45,7 @@ export function createTimelineModel(input: {
})
const ready = createMemo(() => {
const id = input.sessionID()
- return !id || sync().data.message[id] !== undefined
+ return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
})
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
const visibleUserMessages = createMemo(
@@ -98,6 +98,10 @@ export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
+export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
+ return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
+}
+
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
diff --git a/packages/app/src/pages/session/timeline/observe-element-offset.test.ts b/packages/app/src/pages/session/timeline/observe-element-offset.test.ts
new file mode 100644
index 0000000000..d656a31415
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/observe-element-offset.test.ts
@@ -0,0 +1,199 @@
+import { expect, test } from "bun:test"
+import { type Virtualizer } from "@tanstack/solid-virtual"
+import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
+
+test("matches only the scroll element or an ancestor containing it", () => {
+ const route = document.createElement("section")
+ const viewport = document.createElement("div")
+ const child = document.createElement("div")
+ const sibling = document.createElement("div")
+ route.append(viewport)
+ viewport.append(child)
+
+ expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
+ expect(mutationNodesContainElement([route], viewport)).toBe(true)
+ expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
+})
+
+test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
+ const route = document.createElement("section")
+ const viewport = document.createElement("div")
+ const unrelated = document.createElement("div")
+ route.append(viewport)
+ document.body.append(route)
+ const instance = {
+ scrollElement: viewport,
+ targetWindow: window,
+ scrollOffset: 79_400,
+ options: {
+ horizontal: false,
+ isRtl: false,
+ isScrollingResetDelay: 0,
+ useScrollendEvent: false,
+ },
+ } as unknown as Virtualizer
+ const calls: [number, boolean][] = []
+ const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
+ calls.push([offset, isScrolling])
+ instance.scrollOffset = offset
+ })
+
+ document.body.append(unrelated)
+ unrelated.remove()
+ await frames(2)
+ expect(calls).toEqual([])
+
+ route.remove()
+ document.body.append(route)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await frames(3)
+ expect(calls).toEqual([[0, false]])
+
+ route.remove()
+ document.body.append(route)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await frames(3)
+ expect(calls).toEqual([[0, false]])
+
+ cleanup?.()
+ route.remove()
+})
+
+test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
+ const route = document.createElement("section")
+ const viewport = document.createElement("div")
+ route.append(viewport)
+ document.body.append(route)
+ const instance = {
+ scrollElement: viewport,
+ targetWindow: window,
+ scrollOffset: 79_400,
+ options: {
+ horizontal: false,
+ isRtl: false,
+ isScrollingResetDelay: 20,
+ useScrollendEvent: false,
+ },
+ } as unknown as Virtualizer
+ const calls: number[] = []
+ const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
+ calls.push(offset)
+ instance.scrollOffset = offset
+ })
+
+ route.remove()
+ document.body.append(route)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await frames(1)
+ expect(instance.scrollOffset).toBe(0)
+
+ instance.scrollOffset = 79_400
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ await frames(3)
+
+ expect(instance.scrollOffset).toBe(0)
+ expect(calls).toEqual([0, 0])
+ cleanup?.()
+ route.remove()
+})
+
+test.each([
+ { name: "LTR", isRtl: false, expected: 240 },
+ { name: "RTL", isRtl: true, expected: -240 },
+])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
+ const route = document.createElement("section")
+ const viewport = document.createElement("div")
+ route.append(viewport)
+ document.body.append(route)
+ viewport.scrollLeft = 240
+ const instance = {
+ scrollElement: viewport,
+ targetWindow: window,
+ scrollOffset: 0,
+ options: {
+ horizontal: true,
+ isRtl,
+ isScrollingResetDelay: 0,
+ useScrollendEvent: false,
+ },
+ } as unknown as Virtualizer
+ const calls: [number, boolean][] = []
+ const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
+ calls.push([offset, isScrolling])
+ instance.scrollOffset = offset
+ })
+
+ route.remove()
+ document.body.append(route)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await frames(3)
+
+ expect(calls).toEqual([[expected, false]])
+ cleanup?.()
+ route.remove()
+})
+
+test("cleanup suppresses an already queued delegated offset callback", async () => {
+ const viewport = document.createElement("div")
+ document.body.append(viewport)
+ viewport.scrollTop = 100
+ const instance = {
+ scrollElement: viewport,
+ targetWindow: window,
+ scrollOffset: 0,
+ options: {
+ horizontal: false,
+ isRtl: false,
+ isScrollingResetDelay: 10,
+ useScrollendEvent: false,
+ },
+ } as unknown as Virtualizer
+ const calls: [number, boolean][] = []
+ const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
+ calls.push([offset, isScrolling]),
+ )
+
+ viewport.dispatchEvent(new Event("scroll"))
+ cleanup?.()
+ await new Promise((resolve) => setTimeout(resolve, 25))
+
+ expect(calls).toEqual([[100, true]])
+ viewport.remove()
+})
+
+test("cleanup cancels reconnect checks and delegated offset observation", async () => {
+ const route = document.createElement("section")
+ const viewport = document.createElement("div")
+ route.append(viewport)
+ document.body.append(route)
+ const instance = {
+ scrollElement: viewport,
+ targetWindow: window,
+ scrollOffset: 0,
+ options: {
+ horizontal: false,
+ isRtl: false,
+ isScrollingResetDelay: 50,
+ useScrollendEvent: false,
+ },
+ } as unknown as Virtualizer
+ const calls: number[] = []
+ const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
+
+ route.remove()
+ document.body.append(route)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ cleanup?.()
+ instance.scrollOffset = 100
+ viewport.dispatchEvent(new Event("scroll"))
+ await frames(4)
+
+ expect(calls).toEqual([])
+ route.remove()
+})
+
+async function frames(count: number) {
+ for (let index = 0; index < count; index++) {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()))
+ }
+}
diff --git a/packages/app/src/pages/session/timeline/observe-element-offset.ts b/packages/app/src/pages/session/timeline/observe-element-offset.ts
new file mode 100644
index 0000000000..e815423a66
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/observe-element-offset.ts
@@ -0,0 +1,73 @@
+import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
+
+export function observeElementOffsetReconnectAware(
+ instance: Virtualizer,
+ callback: (offset: number, isScrolling: boolean) => void,
+) {
+ let active = true
+ const deliver = (offset: number, isScrolling: boolean) => {
+ if (!active) return
+ callback(offset, isScrolling)
+ }
+ const cleanupOffset = observeElementOffset(instance, deliver)
+ const element = instance.scrollElement
+ const targetWindow = instance.targetWindow
+ const root = element?.closest("main") ?? element?.ownerDocument.body
+ if (!element || !targetWindow || !root)
+ return () => {
+ active = false
+ cleanupOffset?.()
+ }
+
+ let removed = false
+ let frame: number | undefined
+ const clearCheck = () => {
+ if (frame === undefined) return
+ targetWindow.cancelAnimationFrame(frame)
+ frame = undefined
+ }
+ const startCheck = () => {
+ clearCheck()
+ const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
+ let framesAfterDeadline = 0
+ const check = (time: number) => {
+ frame = undefined
+ if (element.isConnected) {
+ const offset = instance.options.horizontal
+ ? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
+ : element.scrollTop
+ if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
+ }
+ if (time >= deadline) framesAfterDeadline += 1
+ if (framesAfterDeadline >= 2) return
+ frame = targetWindow.requestAnimationFrame(check)
+ }
+ frame = targetWindow.requestAnimationFrame(check)
+ }
+ const observer = new targetWindow.MutationObserver((records) => {
+ if (!active) return
+ records.forEach((record) => {
+ if (record.target === element || element.contains(record.target)) return
+ if (mutationNodesContainElement(record.removedNodes, element)) {
+ removed = true
+ clearCheck()
+ }
+ if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
+ removed = false
+ startCheck()
+ })
+ })
+ // Session routes are replaced below persistent main; body is the fallback for isolated hosts.
+ observer.observe(root, { childList: true, subtree: true })
+
+ return () => {
+ active = false
+ observer.disconnect()
+ clearCheck()
+ cleanupOffset?.()
+ }
+}
+
+export function mutationNodesContainElement(nodes: Iterable, element: Element) {
+ return [...nodes].some((node) => node === element || node.contains(element))
+}
diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts
index ec2a3190ba..ea8ea4f132 100644
--- a/packages/app/src/pages/session/timeline/projection.ts
+++ b/packages/app/src/pages/session/timeline/projection.ts
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[]
status: Accessor
showReasoningSummaries: Accessor
+ inlineComments: Accessor
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
+ input.inlineComments(),
),
),
),
diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts
index 6a9cb669d0..a9c9a2d288 100644
--- a/packages/app/src/pages/session/timeline/rows.ts
+++ b/packages/app/src/pages/session/timeline/rows.ts
@@ -1,7 +1,8 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
-import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
+import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
+import { uniqueSummaryDiffs } from "./summary-diffs"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -38,6 +39,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
+ // v2 renders comments inside the user message attachments row instead of a strip row
+ inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
@@ -74,7 +77,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
- if (comments.length > 0)
+ if (comments.length > 0 && !inlineComments)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
@@ -84,7 +87,7 @@ export namespace Timeline {
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
- anchor: comments.length === 0,
+ anchor: inlineComments || comments.length === 0,
}),
)
@@ -135,14 +138,7 @@ export namespace Timeline {
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
- const diffs = (userMessage.summary?.diffs ?? [])
- .reduceRight((result, diff) => {
- if (!isSummaryDiff(diff)) return result
- if (result.some((item) => item.file === diff.file)) return result
- result.push(diff)
- return result
- }, [])
- .reverse()
+ const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs)
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
@@ -167,10 +163,6 @@ export namespace Timeline {
return rows
}
- function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
- return typeof value.file === "string"
- }
-
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i)
diff --git a/packages/app/src/pages/session/timeline/summary-diffs.test.ts b/packages/app/src/pages/session/timeline/summary-diffs.test.ts
new file mode 100644
index 0000000000..9b66bf6771
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/summary-diffs.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test"
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import { uniqueSummaryDiffs } from "./summary-diffs"
+
+const diff = (file: string, additions: number) =>
+ ({
+ file,
+ additions,
+ deletions: 0,
+ }) satisfies SnapshotFileDiff
+
+describe("uniqueSummaryDiffs", () => {
+ test("drops entries without files and preserves unique input", () => {
+ const alpha = diff("alpha.ts", 1)
+ const beta = diff("beta.ts", 1)
+ const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff
+
+ expect(uniqueSummaryDiffs(undefined)).toEqual([])
+ expect(uniqueSummaryDiffs([])).toEqual([])
+ expect(uniqueSummaryDiffs([invalid])).toEqual([])
+
+ const result = uniqueSummaryDiffs([alpha, invalid, beta])
+ expect(result).toEqual([alpha, beta])
+ expect(result[0]).toBe(alpha)
+ expect(result[1]).toBe(beta)
+ })
+
+ test("keeps the last diff per file in the legacy display order", () => {
+ const oldAlpha = diff("alpha.ts", 1)
+ const oldBeta = diff("beta.ts", 1)
+ const newAlpha = diff("alpha.ts", 2)
+ const charlie = diff("charlie.ts", 1)
+ const newBeta = diff("beta.ts", 2)
+
+ const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta])
+
+ expect(result).toEqual([newAlpha, charlie, newBeta])
+ expect(result[0]).toBe(newAlpha)
+ expect(result[1]).toBe(charlie)
+ expect(result[2]).toBe(newBeta)
+ })
+})
diff --git a/packages/app/src/pages/session/timeline/summary-diffs.ts b/packages/app/src/pages/session/timeline/summary-diffs.ts
new file mode 100644
index 0000000000..2df37af5a6
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/summary-diffs.ts
@@ -0,0 +1,20 @@
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { SummaryDiff } from "./timeline-row"
+
+export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
+ const files = new Set()
+ return (diffs ?? [])
+ .reduceRight((result, diff) => {
+ if (!isSummaryDiff(diff)) return result
+ const file = diff.file
+ if (files.has(file)) return result
+ files.add(file)
+ result.push(diff)
+ return result
+ }, [])
+ .reverse()
+}
+
+function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff {
+ return typeof diff.file === "string"
+}
diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx
index 935eed7e69..d56fa3d1f4 100644
--- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx
+++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx
@@ -76,9 +76,11 @@ export function useUsageExceededDialogs() {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
- void import("../../components/dialog-connect-provider").then((x) =>
- dialog.show(() => ),
- )
+ void import("../../components/dialog-connect-provider").then((x) => {
+ const controller = x.useProviderConnectController()
+ controller.select("opencode-go")
+ void dialog.show(() => )
+ })
}
}}
/>
diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx
index 98239010f5..e7e51489ee 100644
--- a/packages/app/src/pages/session/use-composer-commands.tsx
+++ b/packages/app/src/pages/session/use-composer-commands.tsx
@@ -1,7 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
-import { useLocal } from "@/context/local"
-import { useSettings } from "@/context/settings"
+import { useLocal, type ModelSelection } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
@@ -14,14 +13,14 @@ const withCategory = (category: string) => {
})
}
-export const useComposerCommands = () => {
+export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
- const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
+ const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
@@ -43,7 +42,7 @@ export const useComposerCommands = () => {
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
- void dialog.show(() => , restoreComposer)
+ void dialog.show(() => , restoreComposer)
})
}
@@ -61,7 +60,7 @@ export const useComposerCommands = () => {
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
- onSelect: () => local.model.variant.cycle(),
+ onSelect: () => model.variant.cycle(),
}),
agentCommand({
id: "agent.cycle",
@@ -69,7 +68,7 @@ export const useComposerCommands = () => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
- disabled: !settings.visibility.customAgents(),
+ disabled: !local.agent.visible(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -77,7 +76,7 @@ export const useComposerCommands = () => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
- disabled: !settings.visibility.customAgents(),
+ disabled: !local.agent.visible(),
onSelect: () => local.agent.move(-1),
}),
])
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index fd2e29d31c..275e6ec4bc 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -18,8 +18,6 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
-import { useTabs } from "@/context/tabs"
-import { requireServerKey } from "@/utils/session-route"
import { createSessionOwnership } from "./session-ownership"
export type SessionCommandContext = {
@@ -27,6 +25,7 @@ export type SessionCommandContext = {
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
+ fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -48,7 +47,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
- const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
@@ -86,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
normalizeTab,
review: actions.review,
hasReview,
+ fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
@@ -265,10 +264,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openTerminal = () => {
- if (terminal.all().length > 0) terminal.new()
+ if (terminal.all().length > 0) terminal.new({ focus: true })
+ if (terminal.all().length === 0) terminal.requestFocus()
view().terminal.open()
}
+ const closeTerminal = () => {
+ const id = terminal.active()
+ if (!id) return
+ const last = terminal.all().length === 1
+ void terminal.close(id)
+ if (last) view().terminal.close()
+ }
+
const chooseMcp = () => {
void openDialog(
() => import("@/components/dialog-select-mcp"),
@@ -411,9 +419,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
- onSelect: () => {
- if (params.serverKey) {
- sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
+ onSelect: (source) => {
+ if (settings.general.newLayoutDesigns()) {
+ command.trigger("tab.new", source)
return
}
navigate(`/${params.dir}/session`)
@@ -460,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
- keybind: "mod+k,mod+p",
+ keybind: "mod+p",
slash: "open",
onSelect: openFile,
}),
@@ -491,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
- onSelect: () => view().terminal.toggle(),
+ onSelect: () => {
+ if (view().terminal.opened()) {
+ terminal.cancelFocus()
+ view().terminal.close()
+ return
+ }
+ terminal.requestFocus(terminal.active())
+ view().terminal.open()
+ },
}),
viewCommand({
id: "review.toggle",
@@ -518,6 +534,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
]
const terminalCmds = () => [
+ terminalCommand({
+ id: "terminal.close",
+ title: language.t("terminal.close"),
+ keybind: "mod+w",
+ hidden: true,
+ when: (event) => event.target instanceof Element && !!event.target.closest('[data-component="terminal"]'),
+ onSelect: closeTerminal,
+ }),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts
index b5a26cb321..2d069cdb18 100644
--- a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts
+++ b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
+import { filterReviewFiles, reviewDiffDirectory, reviewDiffKinds, reviewDiffNeedsLoad } from "./review-diff-kinds"
describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => {
@@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => {
expect(kinds.get("src/b.ts")).toBe("del")
expect(kinds.get("src")).toBe("mix")
})
+
+ test("normalizes file and directory paths", () => {
+ const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
+
+ expect(kinds.get("src/lib/a.ts")).toBe("mix")
+ expect(kinds.get("src/lib")).toBe("mix")
+ })
})
describe("filterReviewFiles", () => {
@@ -21,3 +28,43 @@ describe("filterReviewFiles", () => {
expect(filterReviewFiles(files, "")).toEqual(files)
})
})
+
+describe("reviewDiffNeedsLoad", () => {
+ test("loads changed files whose aggregate patch has no hunks", () => {
+ expect(
+ reviewDiffNeedsLoad({
+ file: "src/a.ts",
+ additions: 1,
+ deletions: 0,
+ patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts",
+ }),
+ ).toBe(true)
+ })
+
+ test("keeps complete patches and empty changes", () => {
+ expect(
+ reviewDiffNeedsLoad({
+ file: "src/a.ts",
+ additions: 1,
+ deletions: 0,
+ patch: "@@ -0,0 +1 @@\n+value",
+ }),
+ ).toBe(false)
+ expect(reviewDiffNeedsLoad({ file: "empty.txt", additions: 0, deletions: 0 })).toBe(false)
+ })
+})
+
+describe("reviewDiffDirectory", () => {
+ test("scopes nested files to their parent directory", () => {
+ expect(reviewDiffDirectory("/repo", "src/lib/a.ts")).toBe("/repo/src/lib")
+ expect(reviewDiffDirectory("C:\\repo", "src/lib/a.ts")).toBe("C:\\repo\\src\\lib")
+ })
+
+ test("does not rescope root files", () => {
+ expect(reviewDiffDirectory("/repo/", "README.md")).toBe("/repo")
+ expect(reviewDiffDirectory("/", "README.md")).toBe("/")
+ expect(reviewDiffDirectory("C:\\", "README.md")).toBe("C:\\")
+ expect(reviewDiffDirectory("/", "src/a.ts")).toBe("/src")
+ expect(reviewDiffDirectory("C:\\", "src/a.ts")).toBe("C:\\src")
+ })
+})
diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts
index 4d22d9a67a..49cec334bc 100644
--- a/packages/app/src/pages/session/v2/review-diff-kinds.ts
+++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts
@@ -1,16 +1,35 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
+import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
- return p.replaceAll("\\", "/").replace(/\/+$/, "")
+ return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
+export function reviewDiffNeedsLoad(diff: RenderDiff) {
+ if (diff.additions === 0 && diff.deletions === 0) return false
+ return !diff.patch || !/^@@ /m.test(diff.patch)
+}
+
+export function reviewRootDirectory(root: string) {
+ return root === "/" || /^[A-Za-z]:[/\\]?$/.test(root) ? root : root.replace(/[/\\]+$/, "")
+}
+
+export function reviewDiffDirectory(root: string, file: string) {
+ const path = normalizePath(file)
+ const index = path.lastIndexOf("/")
+ const separator = root.includes("\\") ? "\\" : "/"
+ const base = reviewRootDirectory(root)
+ if (index < 0) return base
+ return `${base.endsWith(separator) ? base : base + separator}${path.slice(0, index).replaceAll("/", separator)}`
+}
+
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx
index 0e809fbf68..4f0cf612e1 100644
--- a/packages/app/src/pages/session/v2/review-panel-v2.tsx
+++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx
@@ -1,11 +1,10 @@
-import { createMemo, createSignal, Show, type JSX } from "solid-js"
+import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
- SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -25,6 +24,7 @@ import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
+ reviewDiffNeedsLoad,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
@@ -37,6 +37,8 @@ export type ReviewPanelV2Props = {
empty?: JSX.Element
diffs: () => ReviewDiff[]
diffsReady: () => boolean
+ diffVersion?: number
+ loadDiff?: (path: string, version?: number) => Promise
activeFile?: string
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
@@ -63,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
+ // Changes-only trees omit "M" — every row is already a change; A/D stay visible.
+ const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
@@ -74,7 +78,26 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
if (active && files.includes(active)) return active
return files[0]
})
- const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
+ const sourceActiveItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
+ const detailSource = createMemo(() => {
+ const diff = sourceActiveItem()
+ const load = props.loadDiff
+ if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
+ return { diff, load, version: props.diffVersion }
+ })
+ const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
+ const value = await load(diff.file, version)
+ if (value?.file !== diff.file) return
+ return { source: diff, version, value }
+ })
+
+ const activeItem = createMemo(() => {
+ const source = sourceActiveItem()
+ if (loadedDiff.state !== "ready") return source
+ const loaded = loadedDiff()
+ if (loaded && loaded.source === source && loaded.version === props.diffVersion) return loaded.value
+ return source
+ })
const readFile = async (path: string) =>
sdk()
@@ -91,9 +114,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
- sidebarToggle={
-
- }
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@@ -105,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
- kinds={kinds}
+ kinds={treeKinds}
activeDiff={activeDiff}
/>
}
@@ -204,7 +224,6 @@ function ReviewPanelV2Sidebar(props: {
when={props.searching()}
fallback={
boolean
+ sidebarWidth: () => number
+ sidebarTransition: () => boolean
+ resizeSidebar: (width: number) => void
+ toggleSidebar: () => void
+}
+
+export function SessionFileBrowserTab(props: {
+ tab: string
+ placeholder: boolean
+ active?: string
+ kinds: ReadonlyMap
+ state: SessionFileBrowserState
+ diff?: RenderDiff
+ diffVersion?: number
+ loadDiff?: (path: string, version?: number) => Promise
+ expandUnchanged?: boolean
+ onSelect: (path: string) => void
+ onSelectPermanent: (path: string) => void
+ filterRef?: (element: HTMLInputElement) => void
+}) {
+ const file = useFile()
+ const language = useLanguage()
+ const layout = useLayout()
+ const sdk = useSDK()
+ const { workspaceKey } = useSessionLayout()
+ const resultsID = `session-file-browser-results-${createUniqueId()}`
+ const [filter, setFilter] = createSignal("")
+ const [explicitHighlight, setExplicitHighlight] = createSignal()
+ const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
+ const query = createMemo(() => filter().trim())
+ const search = createQuery(() => {
+ const value = query()
+ return {
+ queryKey: ["session-open-file", workspaceKey(), value] as const,
+ enabled: value.length > 0,
+ queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
+ }
+ })
+ const files = createMemo(() => {
+ if (!query() || search.isPending) return emptyFiles
+ return [...new Set(search.data ?? emptyFiles)]
+ })
+ const highlighted = createMemo(() => {
+ const values = files()
+ if (values.length === 0) return undefined
+ const explicit = explicitHighlight()
+ if (explicit && values.includes(explicit)) return explicit
+ return values[0]
+ })
+ const loading = createMemo(() => query().length > 0 && search.isPending)
+ const project = createMemo(() => {
+ const directory = pathKey(sdk().directory)
+ return layout.projects
+ .list()
+ .find(
+ (item) =>
+ pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+ )
+ })
+ const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
+ const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
+
+ const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
+ if (event.key === "Escape" && query()) {
+ event.preventDefault()
+ setFilter("")
+ return
+ }
+ if (!query()) return
+ applyFileListKeyDown(event, files(), highlighted(), {
+ onHighlight: setExplicitHighlight,
+ onSelect: props.onSelectPermanent,
+ })
+ }
+
+ // Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
+ // unmounts the whole panel on every file-tab switch and resets sidebar scroll.
+ return (
+ {title()}}
+ filter={filter()}
+ onFilterChange={setFilter}
+ onFilterKeyDown={onFilterKeyDown}
+ filterAutofocus={props.placeholder}
+ filterRef={props.filterRef}
+ filterControls={resultsID}
+ filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
+ filterExpanded={query().length > 0 && files().length > 0}
+ width={props.state.sidebarWidth()}
+ onWidthChange={props.state.resizeSidebar}
+ >
+ props.onSelect(node.path)}
+ onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
+ />
+ }
+ >
+
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
+
+ }
+ >
+ 0}
+ fallback={
+
+ {language.t("palette.empty")}
+
+ }
+ >
+ {
+ setExplicitHighlight(path)
+ props.onSelect(path)
+ }}
+ onFileDoubleClick={props.onSelectPermanent}
+ />
+
+
+
+
+ }
+ >
+
+
+
+
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+
+ }
+ >
+
+
+ {(tab) => (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx
index dc4da25723..16cac2a45d 100644
--- a/packages/app/src/pages/session/v2/session-file-list-v2.tsx
+++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx
@@ -1,9 +1,11 @@
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import { createEffect, For, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
+import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+import { virtualScrollElement } from "@/components/virtual-scroll-element"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -41,66 +43,121 @@ export function SessionFileListV2(props: {
active?: string
highlighted?: string
kinds?: ReadonlyMap
+ id?: string
+ role?: "listbox"
+ optionID?: (path: string) => string
onFileClick: (path: string) => void
+ onFileDoubleClick?: (path: string) => void
}) {
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
- let rootRef: HTMLDivElement | undefined
+ const normalized = createMemo(() => props.files.map(normalizePath))
+ const [root, setRoot] = createSignal()
+ const [focused, setFocused] = createSignal()
+ const virtualizer = createVirtualizer({
+ get count() {
+ return props.files.length
+ },
+ getScrollElement: () => virtualScrollElement(root()),
+ initialRect: { width: 0, height: 600 },
+ estimateSize: () => 28,
+ gap: 2,
+ overscan: 10,
+ get getItemKey() {
+ const files = props.files
+ return (index: number) => files[index] ?? index
+ },
+ rangeExtractor: (range) => {
+ const indexes = defaultRangeExtractor(range)
+ const path = focused()
+ const index = path ? props.files.indexOf(path) : -1
+ if (index < 0 || indexes.includes(index)) return indexes
+ return [...indexes, index].sort((a, b) => a - b)
+ },
+ })
createEffect(() => {
- highlighted()
- if (!rootRef) return
+ const index = normalized().indexOf(highlighted())
+ if (index < 0) return
queueMicrotask(() => {
- const row = rootRef?.querySelector('[data-slot="file-tree-v2-row"][data-highlighted]')
- row?.scrollIntoView({ block: "nearest" })
+ if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
+ virtualizer.scrollToIndex(index, { align: "auto" })
})
})
+ const virtualItemByKey = createMemo(
+ () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
+ )
+ const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
return (
{
- rootRef = el
- }}
+ ref={setRoot}
+ id={props.id}
+ role={props.role}
data-component="file-tree-v2"
+ data-total-rows={props.files.length}
+ style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
-
- {(path) => {
- const normalized = normalizePath(path)
- const selected = () => {
- if (highlighted()) return highlighted() === normalized
- return active() === normalized
- }
- const highlightedRow = () => highlighted() === normalized
- const kind = () => props.kinds?.get(normalized)
- const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
- const filename = () => getFilename(normalized)
+
+ {(key) => {
+ const path = key as string
+ const value = normalizePath(path)
+ const selected = () => (highlighted() ? highlighted() === value : active() === value)
+ const highlightedRow = () => highlighted() === value
+ const kind = () => props.kinds?.get(value)
+ const directory = () => (value.includes("/") ? getDirectory(value) : undefined)
+ const filename = () => getFilename(value)
return (
-
+
+ {(item) => (
+
+
+
+ )}
+
)
}}
diff --git a/packages/app/src/utils/file-manager.ts b/packages/app/src/utils/file-manager.ts
new file mode 100644
index 0000000000..2fba23cc5a
--- /dev/null
+++ b/packages/app/src/utils/file-manager.ts
@@ -0,0 +1,24 @@
+export type FileManagerOS = "macos" | "windows" | "linux" | "unknown"
+
+export function fileManagerApp(os: FileManagerOS): {
+ label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager"
+ actionLabel:
+ | "session.header.reveal.finder"
+ | "session.header.reveal.fileExplorer"
+ | "session.header.reveal.containingFolder"
+ icon: "finder" | "file-explorer"
+} {
+ if (os === "macos")
+ return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" }
+ if (os === "windows")
+ return {
+ label: "session.header.open.fileExplorer",
+ actionLabel: "session.header.reveal.fileExplorer",
+ icon: "file-explorer",
+ }
+ return {
+ label: "session.header.open.fileManager",
+ actionLabel: "session.header.reveal.containingFolder",
+ icon: "finder",
+ }
+}
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index 671b798f61..a2daae4866 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
}
}
-const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
+const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts
new file mode 100644
index 0000000000..421a2e71fd
--- /dev/null
+++ b/packages/app/test-browser/command-palette.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
+import { createRoot } from "solid-js"
+import { createServerSessionEntries } from "@/components/command-palette"
+import type { LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { getProjectAvatarSource } from "@/pages/layout/helpers"
+
+const stored: Project = {
+ id: "project-1",
+ name: "Palette project",
+ worktree: "/workspace/project",
+ sandboxes: [],
+ time: { created: 1, updated: 1 },
+}
+
+const session: GlobalSession = {
+ id: "session-1",
+ slug: "session-1",
+ projectID: stored.id,
+ directory: stored.worktree,
+ title: "Palette session",
+ version: "1",
+ time: { created: 1, updated: 2 },
+ project: { id: stored.id, name: stored.name, worktree: stored.worktree },
+}
+
+describe("command palette sessions", () => {
+ test("uses the home project avatar and cancels superseded searches", async () => {
+ const server = ServerConnection.Key.make("selected-server")
+ const opened: LocalProject = {
+ ...stored,
+ icon: { override: "home-project-avatar" },
+ expanded: true,
+ }
+ const searches: string[] = []
+ const result = await new Promise>>>(
+ (resolve, reject) => {
+ createRoot((dispose) => {
+ const search = createServerSessionEntries({
+ server,
+ opened: () => [opened],
+ stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
+ load: async (text) => {
+ searches.push(text)
+ return {
+ data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
+ }
+ },
+ untitled: () => "Untitled",
+ category: () => "Sessions",
+ })
+ const first = search("palette")
+ const second = search("palette session")
+ Promise.all([first, second])
+ .then(([cancelled, entries]) => {
+ expect(cancelled).toEqual([])
+ resolve(entries)
+ })
+ .catch(reject)
+ .finally(dispose)
+ })
+ },
+ )
+
+ expect(searches).toEqual(["palette session"])
+ expect(result).toHaveLength(1)
+ expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
+ expect(result[0]).toMatchObject({
+ server,
+ sessionID: session.id,
+ description: stored.name,
+ project: { id: stored.id, icon: opened.icon },
+ })
+ })
+})
diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts
index 55ef8d3922..5777c9c392 100644
--- a/packages/app/test-browser/prompt-transient-state.test.ts
+++ b/packages/app/test-browser/prompt-transient-state.test.ts
@@ -8,6 +8,8 @@ test("resets transient prompt input state when the prompt session changes", () =
const [state, setState] = createPromptInputTransientState(identity, 3)
setState({
popover: "slash",
+ slashMenu: true,
+ slashMenuQuery: "compact",
historyIndex: 2,
savedPrompt: {
prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }],
@@ -16,20 +18,20 @@ test("resets transient prompt input state when the prompt session changes", () =
draggingType: "image",
mode: "shell",
applyingHistory: true,
- variantOpen: true,
})
setIdentity("B")
expect(state).toMatchObject({
popover: null,
+ slashMenu: false,
+ slashMenuQuery: "",
historyIndex: -1,
savedPrompt: null,
placeholder: 3,
draggingType: null,
mode: "normal",
applyingHistory: false,
- variantOpen: false,
})
dispose()
})
diff --git a/packages/app/test-browser/review-panel-v2-state.test.ts b/packages/app/test-browser/review-panel-v2-state.test.ts
new file mode 100644
index 0000000000..2a7862ce17
--- /dev/null
+++ b/packages/app/test-browser/review-panel-v2-state.test.ts
@@ -0,0 +1,65 @@
+import { beforeAll, expect, mock, test } from "bun:test"
+import type { AsyncStorage } from "@solid-primitives/storage"
+import { createEffect, createRoot } from "solid-js"
+
+let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
+let read: ((value: string | null) => void) | undefined
+
+const storage: AsyncStorage = {
+ getItem: () => new Promise((resolve) => (read = resolve)),
+ setItem: async () => undefined,
+ removeItem: async () => undefined,
+ clear: async () => undefined,
+ key: async () => null,
+ getLength: async () => 0,
+ length: Promise.resolve(0),
+}
+
+beforeAll(async () => {
+ mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
+ SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
+ SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
+ SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
+ }))
+ mock.module("@/context/platform", () => ({
+ usePlatform: () => ({ platform: "desktop", storage: () => storage }),
+ }))
+
+ createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
+})
+
+test("enables sidebar motion only after custom width hydration", async () => {
+ await new Promise((resolve, reject) => {
+ createRoot((dispose) => {
+ const state = createReviewPanelV2State()
+ const transition =
+ "sidebarTransition" in state && typeof state.sidebarTransition === "function"
+ ? (state.sidebarTransition as () => boolean)
+ : undefined
+
+ try {
+ expect(transition).toBeFunction()
+ expect(transition?.()).toBeFalse()
+ expect(state.sidebarWidth()).toBe(240)
+ } catch (error) {
+ dispose()
+ reject(error)
+ return
+ }
+
+ createEffect(() => {
+ if (!transition?.()) return
+ try {
+ expect(state.sidebarWidth()).toBe(360)
+ dispose()
+ resolve()
+ } catch (error) {
+ dispose()
+ reject(error)
+ }
+ })
+
+ read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" }))
+ })
+ })
+})
diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts
index 727248d606..9327eacf42 100644
--- a/packages/app/test-browser/solid-virtual.test.ts
+++ b/packages/app/test-browser/solid-virtual.test.ts
@@ -1,8 +1,32 @@
import { expect, test } from "bun:test"
-import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
+test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
+ const writes: { offset: number; adjustments?: number }[] = []
+ const virtualizer = new Virtualizer({
+ count: 5,
+ estimateSize: () => 50,
+ initialOffset: 50,
+ initialRect: { width: 400, height: 200 },
+ anchorTo: "end",
+ scrollEndThreshold: 1,
+ getScrollElement: () => null,
+ scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
+ observeElementRect: () => {},
+ observeElementOffset: () => {},
+ })
+
+ virtualizer.getTotalSize()
+ virtualizer.resizeItem(4, 120)
+ expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
+ writes.length = 0
+
+ virtualizer.resizeItem(4, 200)
+ expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
+})
+
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@@ -27,25 +51,43 @@ test("reactive count updates preserve measured row sizes", () => {
})
})
-test("logical scroll offset includes pending measurement adjustments", () => {
+test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer({
- count: 2,
+ count: 100,
getScrollElement: () => null,
- estimateSize: () => 60,
- initialOffset: 100,
- initialRect: { width: 800, height: 60 },
+ estimateSize: () => 28,
+ initialRect: { width: 0, height: 600 },
+ overscan: 10,
})
- virtualizer.getTotalSize()
- virtualizer.resizeItem(0, 100)
-
- expect(virtualizer.scrollOffset).toBe(100)
- expect(virtualizer.getLogicalScrollOffset()).toBe(140)
+ expect(virtualizer.getVirtualItems().length).toBeGreaterThan(0)
dispose()
})
})
+test("clamps oversized offsets with scroll margin and padding changes", () => {
+ const options = (paddingEnd: number) => ({
+ count: 20,
+ estimateSize: () => 60,
+ initialOffset: Number.MAX_SAFE_INTEGER,
+ initialRect: { width: 800, height: 600 },
+ scrollMargin: 64,
+ paddingEnd,
+ overscan: 1,
+ getScrollElement: () => null,
+ scrollToFn: () => {},
+ observeElementRect: () => {},
+ observeElementOffset: () => {},
+ })
+ const virtualizer = new Virtualizer(options(64))
+
+ expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
+
+ virtualizer.setOptions(options(600))
+ expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
+})
+
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c4dafdb57e..2152bc3e2a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
- "version": "1.17.13",
+ "version": "1.18.3",
"type": "module",
"license": "MIT",
"bin": {
diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts
index 55869a6295..f42d8b07b0 100755
--- a/packages/cli/script/build.ts
+++ b/packages/cli/script/build.ts
@@ -1,7 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
-import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
@@ -51,10 +50,6 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
-const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
-const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
-const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
-
for (const item of targets) {
const target = [
binary,
@@ -68,7 +63,7 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
- entrypoints: ["./src/index.ts", parserWorker],
+ entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
@@ -94,10 +89,6 @@ for (const item of targets) {
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
- OTUI_TREE_SITTER_WORKER_PATH:
- (item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
- path.relative(dir, parserWorker).replaceAll("\\", "/") +
- '"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
diff --git a/packages/codemode/README.md b/packages/codemode/README.md
index 1230b16296..37bde2d869 100644
--- a/packages/codemode/README.md
+++ b/packages/codemode/README.md
@@ -4,7 +4,7 @@ Effect-native confined code execution over explicit, schema-described tools.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
-The package is currently private to this workspace. Its API is designed around three uses:
+The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
```ts
// One execution
@@ -13,9 +13,6 @@ yield * CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code)
-
-// One agent-facing code tool
-const codeTool = runtime.agentTool()
```
## Install
@@ -63,7 +60,7 @@ const result =
`)
```
-`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
+`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
@@ -86,6 +83,8 @@ const tool = Tool.make({
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
+Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
+
### `CodeMode.execute`
Use `CodeMode.execute` for a single execution:
@@ -116,31 +115,32 @@ const runtime = CodeMode.make({
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
-runtime.execute(source) // ExecuteResult
-runtime.agentTool() // { name, description, input, output, execute }
+runtime.execute(source) // CodeMode.Result
```
-`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
+`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
+
+All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
### Results
```ts
-type ExecuteResult = ExecuteSuccess | ExecuteFailure
+type Result = Success | Failure
-interface ExecuteSuccess {
+interface Success {
readonly ok: true
- readonly value: Schema.Json
+ readonly value: CodeMode.DataValue
readonly logs?: ReadonlyArray
readonly truncated?: boolean
- readonly toolCalls: ReadonlyArray
+ readonly toolCalls: ReadonlyArray
}
-interface ExecuteFailure {
+interface Failure {
readonly ok: false
- readonly error: Diagnostic
+ readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray
readonly truncated?: boolean
- readonly toolCalls: ReadonlyArray
+ readonly toolCalls: ReadonlyArray
}
```
@@ -179,14 +179,14 @@ Supported bearer, basic, header, and query authentication follows OpenAPI `secur
## Discovery
-The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
+The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
-The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime:
+The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
```ts
const runtime = CodeMode.make({
tools,
- discovery: { maxInlineCatalogTokens: 6_000 },
+ discovery: { catalogBudget: 6_000 },
})
```
@@ -199,30 +199,37 @@ const matches = await tools.$codemode.search({
query: "order status",
namespace: "orders", // optional: scope to one top-level namespace
limit: 10,
+ offset: 0,
})
```
-`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10).
+`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
-Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form.
+```ts
+const request = { query: "order status", namespace: "orders", limit: 10 }
+const page = await tools.$codemode.search(request)
+const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
+```
+
+Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
```ts
tools.github.list_issues(input: {
/** Repository owner */
- owner: string
+ owner: string,
/** Cursor from the previous response's pageInfo */
- after?: string
+ after?: string,
/**
* Results per page
* @default 30
*/
- perPage?: number
+ perPage?: number,
}): Promise
```
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
-The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name.
+The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name.
A host cannot define its own `$codemode` top-level namespace.
@@ -234,14 +241,15 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
-- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
+- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
-- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
+- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
+- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
-- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
+- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
-Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
+Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
@@ -304,7 +312,7 @@ import { toolError } from "@opencode-ai/codemode"
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
-Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
+Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
## Authority Boundary
@@ -333,12 +341,10 @@ A program cannot gain authority through prose or generated code. It can only exe
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
-- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
-- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
-- Host interruption remains interruption rather than an `ExecuteFailure`.
+- Host interruption remains interruption rather than a `CodeMode.Failure`.
## Non-Goals
diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md
index 41b801044d..f56c405746 100644
--- a/packages/codemode/codemode.md
+++ b/packages/codemode/codemode.md
@@ -1,1218 +1,176 @@
-# CodeMode - Status, Decisions, and Remaining Work
+# CodeMode Design and Status
-This document is the working plan for `@opencode-ai/codemode` and its OpenCode integration.
-It captures every locked decision, everything already implemented, and a detailed TODO of what
-remains - enough context that someone (human or agent) can pick up any item cold.
+This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
+It records current behavior, intentional boundaries, durable rationale, and material remaining work.
-Tracking issue: https://github.com/anomalyco/opencode/issues/34787
-Working branch: `codemode-v2` (base: `dev`)
+Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
+completed work instead of preserving checked-off chronology.
----
+Detailed package API documentation lives in [README.md](./README.md). OpenAPI-specific follow-ups live in
+[src/openapi/TODO.md](./src/openapi/TODO.md).
-## 1. What this is
+## How CodeMode Works
-CodeMode gives a model one `execute` tool that runs JavaScript/TypeScript programs against a
-tree of schema-described tools (`tools.