Merge remote-tracking branch 'origin/v2' into search-integration

# Conflicts:
#	packages/client/src/promise/generated/client.ts
#	packages/client/src/promise/generated/types.ts
#	packages/client/test/promise.test.ts
#	packages/core/src/plugin/host.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/plugin/promise.ts
#	packages/core/test/plugin/host.ts
#	packages/plugin/src/v2/effect/index.ts
#	packages/plugin/src/v2/effect/integration.ts
#	packages/plugin/src/v2/promise/index.ts
#	packages/plugin/src/v2/promise/integration.ts
#	packages/protocol/src/client.ts
#	packages/schema/src/index.ts
#	packages/sdk/js/src/v2/gen/sdk.gen.ts
This commit is contained in:
Shoubhit Dash 2026-07-08 15:09:39 +05:30
commit a6acc2397d
374 changed files with 15254 additions and 10009 deletions

View file

@ -123,8 +123,8 @@ function manageIntegration(
const credentials = credentialConnections(integration)
const selected = createMemo(() => data.location.search.provider() === integration.id)
const selectSearch = () => {
void sdk.api.search
.selectProvider({
void sdk.api.search.provider
.select({
providerID: integration.id,
location: location(data),
})
@ -256,8 +256,8 @@ function KeyMethod(props: {
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.api.integration
.connectKey({
void sdk.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
key,
@ -295,8 +295,8 @@ function OAuthStarting(props: {
const toast = useToast()
onMount(() => {
void sdk.api.integration
.connectOauth({
void sdk.api.integration.connect
.oauth({
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
@ -364,8 +364,8 @@ function OAuthAuto(props: {
}))
const poll = () => {
void sdk.api.integration
.attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) })
void sdk.api.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@ -391,7 +391,7 @@ function OAuthAuto(props: {
onCleanup(() => {
if (timer) clearTimeout(timer)
if (settled) return
void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
@ -421,7 +421,7 @@ function OAuthCode(props: {
onCleanup(() => {
if (settled) return
void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
@ -430,8 +430,8 @@ function OAuthCode(props: {
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.api.integration
.attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code })
void sdk.api.integration.attempt
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)

View file

@ -19,8 +19,9 @@ import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number]
type DialogMoveSessionProps = {
@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (await removedCurrent(deletingCurrent)) return
}
async function create() {
const name = await DialogProjectCopyName.show(dialog)
if (name === null) return
props.onSelect({ type: "new", name })
}
const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
)
@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
onTrigger: () => void create(),
},
{
command: "dialog.move_session.delete",

View file

@ -0,0 +1,95 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useTuiConfig } from "../config"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
function generate() {
input.value = Slug.create()
input.gotoLineEnd()
}
function confirm() {
props.onConfirm(slugify(input.value) || Slug.create())
}
useBindings(() => ({
target: inputTarget,
enabled: inputTarget() !== undefined,
priority: 1,
commands: [
{
name: "dialog.project_copy.generate",
title: "Generate project copy name",
category: "Dialog",
run: generate,
},
],
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!input || input.isDestroyed) return
input.focus()
}, 1)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Name project copy
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<input
ref={(value: InputRenderable) => {
input = value
setInputTarget(value)
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
</text>
</box>
</box>
)
}
DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
})
function slugify(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
}

View file

@ -1,6 +1,6 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import type { SessionInfo } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
@ -44,7 +44,7 @@ export function DialogSessionList() {
workspace: location.workspaceID,
})
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types.
return { query, sessions: structuredClone(response.data) as SessionV2Info[] }
return { query, sessions: structuredClone(response.data) as SessionInfo[] }
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
@ -77,7 +77,7 @@ export function DialogSessionList() {
const pinnedSet = new Set(pinned)
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
const option = (session: SessionV2Info, category: string) => {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
@ -88,12 +88,11 @@ export function DialogSessionList() {
category,
footer,
bg: deleting ? theme.error : undefined,
gutter:
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.accent}>{slot}</text>,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.accent}>{slot}</text>,
}
}
@ -135,16 +134,14 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
void sdk.client.v2.session
.remove({ sessionID: option.value }, { throwOnError: true })
.catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
void sdk.client.v2.session.remove({ sessionID: option.value }, { throwOnError: true }).catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
})
},
},
{

View file

@ -45,10 +45,10 @@ export function DialogSkill(props: DialogSkillProps) {
return list.map((skill) => ({
title: skill.name.padEnd(maxWidth),
description: skill.description?.replace(/\s+/g, " ").trim(),
value: skill.name,
value: skill.id,
category: "Skills",
onSelect: () => {
props.onSelect(skill.name)
props.onSelect(skill.id)
dialog.clear()
},
}))

View file

@ -448,12 +448,12 @@ export function Autocomplete(props: {
for (const skill of data.location.skill
.list(location())
?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) {
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
results.push({
display: "/" + skill.name,
display: "/" + skill.id,
description: skill.description,
onSelect: () => {
const newText = "/" + skill.name + " "
const newText = "/" + skill.id + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)

View file

@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import type { SessionInfo, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner"
@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { lastAssistantWithUsage } from "../../util/session"
registerOpencodeSpinner()
@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) {
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return data.session.family(props.sessionID).filter(
(id) => id !== props.sessionID && data.session.status(id) === "running",
).length
return data.session
.family(props.sessionID)
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
})
const runningShells = createMemo(
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
@ -218,7 +219,10 @@ export function Prompt(props: PromptProps) {
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const move = usePromptMove({
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
sessionID: () => props.sessionID,
})
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
@ -273,18 +277,20 @@ export function Prompt(props: PromptProps) {
const usage = createMemo(() => {
if (!props.sessionID) return
const session = sync.session.get(props.sessionID)
const msg = sync.data.message[props.sessionID] ?? []
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
const session = data.session.get(props.sessionID)
if (!session) return
const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = session?.cost ?? 0
const cost = session.cost
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
@ -955,13 +961,13 @@ export function Prompt(props: PromptProps) {
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const agent = local.agent.current()
if (!agent) return false
const trimmed = store.prompt.text.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
@ -991,7 +997,7 @@ export function Prompt(props: PromptProps) {
const selectedWorkspace = workspace.selection()
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
const directory = await move.getDirectory(store.prompt.text)
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
@ -1022,7 +1028,7 @@ export function Prompt(props: PromptProps) {
sessionID = created.id
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types.
session = structuredClone(created) as SessionV2Info
session = structuredClone(created) as SessionInfo
}
const inputText = expandTrackedPastedText(
@ -1093,7 +1099,7 @@ export function Prompt(props: PromptProps) {
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation()) ?? []).some(
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
@ -1121,7 +1127,7 @@ export function Prompt(props: PromptProps) {
})
}
if (session?.revert) {
const error = await sdk.api.session.revertCommit({ sessionID }).then(
const error = await sdk.api.session.revert.commit({ sessionID }).then(
() => undefined,
(error) => error,
)
@ -1621,11 +1627,11 @@ export function Prompt(props: PromptProps) {
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>

View file

@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
@ -18,38 +18,33 @@ function moveReminderText(directory: string) {
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const homeDestination = useHomeSessionDestination()
const project = useProject()
const data = useData()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
async function create(context?: string) {
const projectID = input.projectID()
async function create(name: string) {
const projectID = await resolveProjectID()
if (!projectID) return
setCreating(true)
setProgress("Creating copy")
try {
const generated = await sdk.client.experimental.projectCopy.generateName(
{ projectID, context },
{ throwOnError: true },
)
const result = await sdk.api.projectCopy.create({
projectID,
location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name,
name,
})
const directory = result.directory
if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped
// before moving on
await sdk.client.path.get({ directory }, { throwOnError: true })
// Call a location-based route to make sure it's bootstrapped before moving on.
await sdk.api.location.get({ location: { directory } })
setProgress("Creating session")
return directory
@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
}
function open() {
const projectID = input.projectID()
if (!projectID) return
async function open() {
const projectID = await resolveProjectID()
if (!projectID) {
toast.show({ message: "Unable to determine current project", variant: "error" })
return
}
const sessionID = input.sessionID()
const session = sessionID ? sync.session.get(sessionID) : undefined
const session = sessionID ? await resolveSession(sessionID) : undefined
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
@ -75,8 +73,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
(session
? {
type: "directory",
directory: session.directory,
subdirectory: !!session.path,
directory: session.location.directory,
subdirectory: !!session.subpath,
}
: {
type: "directory",
@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
))
}
function sessionContext(sessionID: string) {
const session = sync.session.get(sessionID)
const messages = (sync.data.message[sessionID] ?? [])
.slice(-6)
.map((message) =>
[
message.role + ":",
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
].join(" "),
)
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = sync.session.get(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
const session = await resolveSession(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
dialog.clear()
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
setProgress(undefined)
dialog.clear()
@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
setProgress("Moving session")
try {
await sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
await sdk.client.session
.promptAsync({
sessionID,
directory,
noReply: true,
parts: [
{
type: "text",
text: moveReminderText(directory),
synthetic: true,
},
],
})
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await sdk.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
dialog.clear()
} catch (error) {
@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
}
async function resolveProjectID() {
const projectID = input.projectID()
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
return sdk.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } })
.then((project) => project.id)
.catch(() => undefined)
}
async function resolveSession(sessionID: string) {
const session = data.session.get(sessionID)
if (session) return session
await data.session.refresh(sessionID).catch(() => undefined)
return data.session.get(sessionID)
}
const pending = createMemo(() => Boolean(homeDestination?.destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
async function getDirectory(context?: string) {
async function getDirectory() {
const value = homeDestination?.destination()
if (!value) return
if (value.type === "directory") {
return value.directory
}
return await create(context)
return await create(value.name)
}
function startSubmit() {

View file

@ -207,6 +207,7 @@ export const Definitions = {
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),

View file

@ -1,56 +1,71 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// Prefer straightforward projection. Do not add generation counters, stale-response
// merges, live/history overlays, or other race machinery here—last write wins.
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
import type {
AgentV2Info,
CommandV2Info,
AgentInfo,
CommandInfo,
FormFormInfo,
FormIntegrationInfo,
FormUrlInfo,
IntegrationInfo,
LocationRef,
McpServer,
ModelV2Info,
ModelInfo,
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
ReferenceInfo,
SessionMessage,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
SessionInfo,
Shell,
SkillV2Info,
SkillInfo,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
import { batch, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const MESSAGE_PAGE_SIZE = 25
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
// Per-session message timeline plus older-history paging. `items` is ascending;
// `cursor` is the opaque server cursor for the next older page after a desc first load.
type SessionMessages = {
items: SessionMessageInfo[]
cursor?: string
complete: boolean
loading: boolean
}
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcp?: McpServer[]
model?: ModelV2Info[]
model?: ModelInfo[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
searchProvider?: string | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, Shell>
skill?: SkillV2Info[]
skill?: SkillInfo[]
}
type Data = {
session: {
info: Record<string, SessionV2Info>
info: Record<string, SessionInfo>
// Family index keyed by a family's root (or furthest-known-ancestor when the
// true root is not yet loaded). The value is a flat deduplicated list of every
// session ID in that family, including the key itself once its info arrives.
@ -58,7 +73,7 @@ type Data = {
status: Record<string, DataSessionStatus>
compaction: Partial<Record<string, string>>
compactionReason: Partial<Record<string, "auto" | "manual">>
message: Record<string, SessionMessage[]>
message: Record<string, SessionMessages>
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by session ID.
@ -70,6 +85,10 @@ type Data = {
location: Record<string, LocationData>
}
function emptyMessages(): SessionMessages {
return { items: [], complete: false, loading: false }
}
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@ -112,47 +131,42 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
statusChanges?.add(sessionID)
setStore("session", "status", sessionID, status)
}
const message = {
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
"session",
"message",
produce((draft) => {
fn((draft[sessionID] ??= []), index(sessionID))
fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID))
}),
)
},
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
append(messages: SessionMessageInfo[], index: Map<string, number>, item: SessionMessageInfo) {
if (index.has(item.id)) return
index.set(item.id, messages.length)
messages.push(item)
},
activeAssistant(messages: SessionMessage[]) {
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
shell(messages: SessionMessage[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
shell(messages: SessionMessageInfo[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
return item?.type === "shell" ? item : undefined
},
compaction(messages: SessionMessage[]) {
const item = messages.findLast(
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
)
compaction(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
@ -252,6 +266,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.deleted":
removeSession(event.data.sessionID)
break
case "session.usage.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, {
cost: event.data.cost,
tokens: event.data.tokens,
})
break
case "catalog.updated":
void Promise.all([
result.location.model.refresh(event.location),
@ -306,6 +327,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location))
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
}
break
case "session.prompt.promoted": {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
@ -360,7 +387,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "synthetic",
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
time: { created: event.created },
@ -372,7 +398,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "shell",
shell: event.data.shell,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
time: { created: event.created },
})
})
@ -381,7 +410,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft) => {
const match = message.shell(draft, event.data.shell.id)
if (!match) return
match.shell = event.data.shell
match.command = event.data.shell.command
match.status = event.data.shell.status
match.exit = event.data.shell.exit
match.output = event.data.output
match.time.completed = event.created
})
@ -416,7 +447,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.step.ended":
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@ -428,6 +459,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
break
}
case "session.step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
@ -436,6 +468,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.finish = "error"
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
}
})
break
case "session.text.started":
@ -465,7 +501,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: { status: "pending", input: "" },
state: { status: "streaming", input: "" },
})
})
break
@ -475,7 +511,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input += event.data.delta
if (match?.state.status === "streaming") match.state.input += event.data.delta
})
break
case "session.tool.input.ended":
@ -484,7 +520,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input = event.data.text
if (match?.state.status === "streaming") match.state.input = event.data.text
})
break
case "session.tool.called":
@ -536,7 +572,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.data.error,
@ -591,27 +627,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setSessionStatus(event.data.sessionID, "running")
break
case "session.compaction.admitted":
message.update(event.data.sessionID, (draft, index) => {
if (message.compaction(draft)) return
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "")
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
if (event.data.reason === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "running"
message.update(event.data.sessionID, (draft, index) => {
const current = message.compaction(draft)
if (current) {
current.status = "running"
current.reason = event.data.reason
return
}
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
})
break
case "session.execution.succeeded":
case "session.execution.failed":
@ -635,8 +671,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.revert.committed":
if (store.session.info[event.data.sessionID])
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
setStore(
"session",
"input",
@ -651,22 +688,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
if (store.session.compactionReason[event.data.sessionID] === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
})
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
})
break
case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft, index) => {
const current = event.data.reason === "manual" ? message.compaction(draft) : undefined
const current = message.compaction(draft)
if (current) {
current.status = "completed"
current.reason = event.data.reason
current.summary = event.data.text
current.recent = event.data.recent
const position = index.get(current.id)
if (position !== undefined)
draft[position] = {
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
}
return
}
message.append(draft, index, {
@ -683,9 +724,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.compaction.failed":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
message.update(event.data.sessionID, (draft, index) => {
const current = message.compaction(draft)
if (current) current.status = "failed"
if (!current) return
const position = index.get(current.id)
if (position !== undefined)
draft[position] = {
...current,
status: "failed",
reason: event.data.reason,
error: event.data.error,
}
})
break
case "permission.v2.asked":
@ -815,35 +864,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
message: {
ids(sessionID: string) {
return (store.session.message[sessionID] ?? []).map((message) => message.id)
return (store.session.message[sessionID]?.items ?? []).map((message) => message.id)
},
list(sessionID: string) {
return store.session.message[sessionID] ?? []
return store.session.message[sessionID]?.items ?? []
},
get(sessionID: string, messageID: string) {
const messages = store.session.message[sessionID]
const messages = store.session.message[sessionID]?.items
const position = messageIndex.get(sessionID)?.get(messageID)
return position === undefined ? undefined : messages?.[position]
},
cursor(sessionID: string) {
return store.session.message[sessionID]?.cursor
},
complete(sessionID: string) {
return store.session.message[sessionID]?.complete ?? false
},
loading(sessionID: string) {
return store.session.message[sessionID]?.loading ?? false
},
async refresh(sessionID: string) {
const live = [...(store.session.message[sessionID] ?? [])]
setStore("session", "message", sessionID, [])
setStore("session", "message", sessionID, { ...emptyMessages(), loading: true })
messageIndex.set(sessionID, new Map())
const loaded = mutable(
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
).toReversed()
const loadedIDs = new Set(loaded.map((message) => message.id))
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [
...loaded.map((message) => {
if (message.type === "user") return message
return liveByID.get(message.id) ?? message
}),
...live.filter((message) => !loadedIDs.has(message.id)),
].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
const running = messages.find((message) => message.type === "compaction" && message.status === "running")
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" })
const items = mutable(response.data).toReversed()
messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, {
items,
cursor: response.cursor.next ?? undefined,
complete: response.data.length < MESSAGE_PAGE_SIZE,
loading: false,
})
const running = items.find((message) => message.type === "compaction" && message.status === "running")
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
setStore(
"session",
@ -852,6 +904,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
running?.type === "compaction" ? running.reason : undefined,
)
},
async more(sessionID: string) {
const current = store.session.message[sessionID]
if (!current || current.loading || current.complete || !current.cursor) return
const cursor = current.cursor
setStore("session", "message", sessionID, "loading", true)
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor })
const older = mutable(response.data).toReversed()
const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id))
const items = [...prepend, ...current.items]
messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position])))
batch(() => {
setStore("session", "message", sessionID, "items", items)
setStore("session", "message", sessionID, {
cursor: response.cursor.next ?? undefined,
complete: response.data.length < MESSAGE_PAGE_SIZE,
loading: false,
})
})
},
},
permission: {
list(sessionID: string) {
@ -876,7 +947,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.project.permission[projectID]
},
async refresh(projectID: string) {
setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID })))
setStore("project", "permission", projectID, mutable(await sdk.api.permission.saved.list({ projectID })))
},
},
},
@ -983,7 +1054,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.searchProvider ?? undefined
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.search.provider({ location: locationQuery(ref ?? defaultLocation()) })
const result = await sdk.api.search.provider.get({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], searchProvider: result.data ?? null })
},
@ -1021,6 +1092,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
for (const session of response.data) registerSession(session.id)
}),
sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
const permissions = mutable(response.data).reduce<Record<string, PermissionV2Request[]>>(
(result, request) => ({
...result,
[request.sessionID]: [...(result[request.sessionID] ?? []), request],
}),
{},
)
setStore("session", "permission", reconcile(permissions))
}),
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
const forms = mutable(response.data).reduce<Record<string, FormInfo[]>>(
(result, form) => ({
...result,
[form.sessionID]: [...(result[form.sessionID] ?? []), form],
}),
{},
)
setStore("session", "form", reconcile(forms))
}),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
@ -1044,23 +1135,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function refreshActive() {
const generation = ++connectionGeneration
const changed = new Set<string>()
statusChanges = changed
void sdk.api.session
.active()
.then((active) => {
if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
setStore(
"session",
"status",
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
)
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status))
})
.catch(() => undefined)
.finally(() => {
if (statusChanges === changed) statusChanges = undefined
})
}
onCleanup(

View file

@ -15,7 +15,7 @@ import type {
ProviderListResponse,
QuestionRequest,
Session,
SnapshotFileDiff,
FileDiffInfo,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2"
@ -42,9 +42,6 @@ export const {
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleState
capabilities: {
experimentalBackgroundSubagents: boolean
}
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@ -52,7 +49,7 @@ export const {
question: Record<string, QuestionRequest[]>
config: Config
session: Session[]
session_diff: Record<string, SnapshotFileDiff[]>
session_diff: Record<string, FileDiffInfo[]>
todo: Record<string, Todo[]>
message: Record<string, Message[]>
part: Record<string, Part[]>
@ -71,9 +68,6 @@ export const {
connected: [],
},
console_state: emptyConsoleState,
capabilities: {
experimentalBackgroundSubagents: false,
},
provider_auth: {},
agent: [],
command: [],

View file

@ -1,7 +1,8 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
import { useData } from "../../context/data"
import { lastAssistantWithUsage } from "../../util/session"
const id = "internal:sidebar-context"
@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
if (!last) {
return {
tokens: 0,
@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session()?.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,

View file

@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; sessionID: string }) {
function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths()
const theme = () => props.api.theme.current
const has = createMemo(() =>
@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || paths.cwd
const out = abbreviateHome(dir, paths.home)
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const out = abbreviateHome(props.directory, paths.home)
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out
const list = text.split("/")
return {
@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => {
order: 100,
slots: {
sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} />
return <View api={api} directory={props.directory} />
},
},
})

View file

@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
TextAttributes,
type BorderSides,
@ -56,7 +56,7 @@ type DiffFile = {
readonly status: "added" | "deleted" | "modified"
}
const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] =>
const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] =>
diffs.flatMap((item) =>
item.file
? [

View file

@ -10,7 +10,7 @@ import {
import { useSync } from "../../context/sync"
import { useTuiPaths } from "../../context/runtime"
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type Context = {
destination: Accessor<HomeSessionDestination | undefined>

View file

@ -24,7 +24,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
description: "undo messages and file changes",
onSelect: async (dialog) => {
await sdk.api.session
.revertStage({ sessionID: props.sessionID, messageID: props.messageID })
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},

View file

@ -26,14 +26,14 @@ import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../con
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelV2Info,
SessionMessage,
ModelInfo,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionMessageUser,
SessionV2Info,
SessionInfo,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
@ -130,7 +130,7 @@ const context = createContext<{
showGenericToolOutput: () => boolean
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelV2Info[]
models: () => ModelInfo[]
tui: ReturnType<typeof useTuiConfig>
}>()
@ -172,16 +172,6 @@ export function Session() {
})
onCleanup(() => setEpilogue())
const messages = sessionMessages
const transientCompaction = createMemo(() => {
if (
messages().some(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
)
return
const text = data.session.compaction(route.sessionID)
return text === undefined ? undefined : { text }
})
const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return []
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
@ -208,6 +198,16 @@ export function Session() {
?.id
})
// Admitted inputs and in-flight manual compaction sit after history, not in rows.
const pendingMessages = createMemo(() => {
const boundary = session()?.revert?.messageID
return messages().filter((message) => {
if (boundary && message.id >= boundary) return false
if (data.session.input.has(route.sessionID, message.id)) return true
return message.type === "compaction" && message.status === "running"
})
})
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.type === "assistant")
})
@ -251,37 +251,44 @@ export function Session() {
}),
)
createEffect(() => {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.form.refresh(sessionID),
])
const info = data.session.get(sessionID)
if (!info) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
duration: 5000,
createEffect(
on(
() => route.sessionID,
(sessionID) => {
void (async () => {
if (data.session.message.list(sessionID).length === 0) {
await Promise.all([
data.session.refresh(sessionID),
data.session.message.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.form.refresh(sessionID),
])
}
const info = data.session.get(sessionID)
if (!info) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
return
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
message: errorMessage(error),
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
})
navigate({ type: "home" })
return
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
message: errorMessage(error),
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
})
})
},
),
)
let seeded = false
let scroll: ScrollBoxRenderable
@ -337,12 +344,14 @@ export function Session() {
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (direction === "prev") loadOlder()
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (direction === "prev") loadOlder()
dialog.clear()
}
@ -353,6 +362,24 @@ export function Session() {
}, 50)
}
let loadingOlder = false
function loadOlder() {
if (loadingOlder || scroll.scrollTop > 2) return
loadingOlder = true
const before = scroll.scrollHeight
void data.session.message.more(route.sessionID).then(
() => {
setTimeout(() => {
if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before)
loadingOlder = false
}, 50)
},
() => {
loadingOlder = false
},
)
}
const sessionCommandList = createMemo(() => [
{
title: "Share session",
@ -437,7 +464,7 @@ export function Session() {
dialog.clear()
return
}
const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then(
const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then(
() => undefined,
(error) => error,
)
@ -454,7 +481,7 @@ export function Session() {
slash: { name: "redo" },
run: () => {
void (async () => {
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
@ -558,6 +585,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
loadOlder()
dialog.clear()
},
},
@ -578,6 +606,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-1)
loadOlder()
dialog.clear()
},
},
@ -598,6 +627,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
loadOlder()
dialog.clear()
},
},
@ -618,6 +648,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollTo(0)
loadOlder()
dialog.clear()
},
},
@ -927,6 +958,9 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={(event) => {
if (event.scroll?.direction === "up") void loadOlder()
}}
>
<For each={rows}>
{(row) => (
@ -936,9 +970,13 @@ export function Session() {
/>
)}
</For>
<Show when={transientCompaction()}>
{(compaction) => <CompactionMessage status="running" text={compaction().text} />}
</Show>
<For each={pendingMessages()}>
{(message) => (
<box marginTop={1} flexShrink={0}>
<SessionMessageView message={message} />
</box>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
@ -1024,7 +1062,7 @@ export function Session() {
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) {
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
return (
<box marginTop={1} flexShrink={0}>
<Switch>
@ -1062,7 +1100,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
)
}
function BackgroundToolHint(props: { messages: SessionMessage[] }) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { theme } = useTheme()
const shortcut = useCommandShortcut("session.background")
const visible = createMemo(() => {
@ -1090,14 +1128,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) {
)
}
function SessionMessageView(props: { message: SessionMessage }) {
function SessionMessageView(props: { message: SessionMessageInfo }) {
return (
<Switch>
<Match when={props.message.type === "user"}>
<UserMessage message={props.message as SessionMessageUser} />
</Match>
<Match when={props.message.type === "shell"}>
<ShellMessage message={props.message as Extract<SessionMessage, { type: "shell" }>} />
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
</Match>
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
<SessionSwitchMessageV2 message={props.message} />
@ -1106,17 +1144,17 @@ function SessionMessageView(props: { message: SessionMessage }) {
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
>
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
<SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} />
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
</Show>
</Match>
<Match when={props.message.type === "compaction"}>
<CompactionMessage message={props.message as Extract<SessionMessage, { type: "compaction" }>} />
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
</Match>
</Switch>
)
}
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) {
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
const message = createMemo(() => props.message(props.partRef.messageID))
const part = createMemo(() => {
const item = message()
@ -1150,7 +1188,7 @@ function SessionGroupView(props: {
refs: PartRef[]
pending: PartRef[]
completed: boolean
message: (messageID: string) => SessionMessage | undefined
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const { theme } = useTheme()
const ctx = use()
@ -1260,7 +1298,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
)
}
function SessionSwitchMessageV2(props: { message: SessionMessage }) {
function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const { theme } = useTheme()
const text = () => {
@ -1276,7 +1314,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
)
}
function SessionNoticeMessageV2(props: { message: SessionMessage }) {
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const { theme } = useTheme()
const text = () => {
if (props.message.type === "system") return "Instructions updated"
@ -1290,7 +1328,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
)
}
function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) {
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
const { theme } = useTheme()
return (
<InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}>
@ -1300,7 +1338,7 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
}
function CompactionMessage(props: {
message?: Extract<SessionMessage, { type: "compaction" }>
message?: Extract<SessionMessageInfo, { type: "compaction" }>
status?: "running"
text?: string
}) {
@ -1308,9 +1346,10 @@ function CompactionMessage(props: {
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status
const text = () => props.message?.summary ?? props.text ?? ""
const text = () =>
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "")
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
const border = () => (status() === "queued" ? theme.border : color())
const border = color
return (
<box>
<box flexDirection="row" alignItems="center">
@ -1328,11 +1367,8 @@ function CompactionMessage(props: {
<Match when={status() === "failed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "queued"}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>{status() === "queued" ? "Compaction queued" : "Compaction"}</text>
<text fg={color()}>Compaction</text>
</box>
<box border={["top"]} borderColor={border()} flexGrow={1} />
</box>
@ -1363,7 +1399,7 @@ function statusLabel(status: "added" | "modified" | "deleted") {
function RevertMessage(props: {
count: number
files: ReadonlyArray<{
readonly path: string
readonly file: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
@ -1383,7 +1419,7 @@ function RevertMessage(props: {
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
void (async () => {
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
@ -1412,7 +1448,7 @@ function RevertMessage(props: {
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(file.status)}</text>
<text fg={theme.text} wrapMode="none">
{Locale.truncateLeft(file.path, 60)}
{Locale.truncateLeft(file.file, 60)}
</text>
<Show when={file.additions > 0}>
<text fg={theme.diffAdded}>+{file.additions}</text>
@ -1433,7 +1469,7 @@ function RevertMessage(props: {
)
}
function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell" }> }) {
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const { theme } = useTheme()
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
@ -1448,7 +1484,7 @@ function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell"
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
>
<text fg={theme.text}>$ {props.message.shell.command}</text>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.textMuted}>{output()}</text>
</Show>
@ -1560,7 +1596,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
.map((part) =>
part.type === "tool" &&
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
part.state.status !== "pending"
part.state.status !== "streaming"
? part
: undefined,
)
@ -1832,7 +1868,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
const data = useData()
const display = createMemo(() => toolDisplay(props.part.name))
const activeBackgroundWork = createMemo(() => {
if (props.part.state.status === "pending") return false
if (props.part.state.status === "streaming") return false
if (display() === "shell") {
const shellID = stringValue(props.part.state.structured.shellID)
return Boolean(shellID && data.shell.get(shellID))
@ -1856,13 +1892,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
const toolprops = {
get metadata() {
return props.part.state.status === "pending" ? {} : props.part.state.structured
return props.part.state.status === "streaming" ? {} : props.part.state.structured
},
get input() {
return typeof props.part.state.input === "string" ? {} : props.part.state.input
},
get output() {
if (props.part.state.status === "pending") return undefined
if (props.part.state.status === "streaming") return undefined
return props.part.state.content
.flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri]))
.join("\n")
@ -1908,7 +1944,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "execute"}>
<Execute {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
<Match when={display() === "patch"}>
<ApplyPatch {...toolprops} />
</Match>
<Match when={display() === "todowrite"}>
@ -2187,7 +2223,7 @@ function Shell(props: ToolProps) {
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const output = createMemo(() => {
if (props.part.state.status === "pending") return ""
if (props.part.state.status === "streaming") return ""
if (shellID()) return ""
const content = props.part.state.content[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
@ -2209,7 +2245,7 @@ function Shell(props: ToolProps) {
<Show
when={command()}
fallback={
isRunning() || props.part.state.status === "pending" ? (
isRunning() || props.part.state.status === "streaming" ? (
<Spinner color={color()}>Writing command...</Spinner>
) : (
<text fg={theme.textMuted}>Writing command...</text>
@ -2425,7 +2461,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
function Execute(props: ToolProps) {
const ctx = use()
const { theme } = useTheme()
const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
const hasRuntimeError = createMemo(() => props.metadata.error === true)
@ -2521,7 +2557,7 @@ function Edit(props: ToolProps) {
: "# Preparing edit..."
}
part={props.part}
spinner={props.part.state.status === "pending"}
spinner={props.part.state.status === "streaming"}
/>
</Match>
</Switch>
@ -2613,7 +2649,7 @@ function ApplyPatch(props: ToolProps) {
: "# Preparing patch..."
}
part={props.part}
spinner={props.part.state.status === "pending"}
spinner={props.part.state.status === "streaming"}
/>
</Match>
</Switch>
@ -2744,7 +2780,7 @@ const toolDisplays = new Set([
"edit",
"subagent",
"execute",
"apply_patch",
"patch",
"todowrite",
"question",
"skill",
@ -2753,7 +2789,7 @@ const toolDisplays = new Set([
export function toolDisplay(tool: string) {
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
return toolDisplays.has(normalized) ? normalized : "generic"
}
@ -2763,15 +2799,15 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
}
function formatSessionTranscript(
session: SessionV2Info,
messages: SessionMessage[],
session: SessionInfo,
messages: SessionMessageInfo[],
thinking: boolean,
toolDetails: boolean,
) {
const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell")
return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``]
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
if (message.type !== "assistant") return []
const content = message.content.flatMap((item) => {
if (item.type === "text") return [item.text]
@ -2781,7 +2817,7 @@ function formatSessionTranscript(
const output =
item.state.status === "error"
? item.state.error.message
: item.state.status === "pending"
: item.state.status === "streaming"
? ""
: item.state.content
.flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri]))

View file

@ -148,7 +148,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return {}
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
return {}
})

View file

@ -1,6 +1,6 @@
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { useData } from "../../context/data"
export type PartRef = {
@ -25,11 +25,21 @@ export function createSessionRows(sessionID: Accessor<string>) {
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
function pendingIDs() {
const inputs = data.session.input.list(sessionID())
const pending = new Set(inputs)
for (const message of data.session.message.list(sessionID())) {
if (message.type === "compaction" && message.status === "running") pending.add(message.id)
}
return pending
}
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const boundary = revertBoundary()
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
const visible = boundary ? messages.filter((message) => message.id < boundary) : messages
const pending = pendingIDs()
const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id)))
partitionPending(rows, pendingPermissions())
return rows
}
@ -52,48 +62,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
})
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduce()))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
},
() => undefined,
)
on(sessionID, () => {
setRows(reduce())
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
setRows(reduce())
}),
)
// Pending inputs and compaction leaving the pending set change history membership.
createEffect(
on(
() =>
data.session.message.list(sessionID()).flatMap((message) =>
message.type === "user"
? [
{
id: message.id,
created: message.time.created,
input: data.session.input.has(sessionID(), message.id),
},
]
: message.type === "compaction"
? [
{
id: message.id,
created: message.time.created,
input: message.status === "queued" || message.status === "running",
},
]
: [],
),
() => setRows(reconcile(reduce())),
() => {
const messages = data.session.message.list(sessionID())
const pending = data.session.input.list(sessionID()).join("\0")
const compaction = messages
.filter((message) => message.type === "compaction")
.map((message) => `${message.id}:${message.status}`)
.join("\0")
return `${pending}\u0001${compaction}`
},
() => setRows(reduce()),
),
)
@ -101,12 +93,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index =
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
if (pendingIDs().has(messageID)) return
completePrevious(draft)
draft.push({ type: "message", messageID })
}),
)
@ -114,15 +103,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
const index = queuedStart(draft)
if (name && exploration(name)) {
const previous = draft[index - 1]
const previous = draft.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(draft, index)
draft.splice(index, 0, {
completePrevious(draft)
draft.push({
type: "group",
kind: "exploration",
refs: [ref],
@ -131,8 +119,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
})
return
}
completePrevious(draft, index)
draft.splice(index, 0, { type: "part", ref })
completePrevious(draft)
draft.push({ type: "part", ref })
}),
)
@ -140,9 +128,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
const index = queuedStart(draft)
completePrevious(draft, index)
draft.splice(index, 0, { type: "assistant-footer", messageID })
completePrevious(draft)
draft.push({ type: "assistant-footer", messageID })
}),
)
@ -154,26 +141,13 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const isPending = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
return message?.type === "compaction" && (message.status === "queued" || message.status === "running")
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
return index === -1 ? rows.length : index
}
const message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
}
const input = (event: { data: { sessionID: string; inputID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.compaction.admitted", input),
data.on("session.prompt.promoted", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
}),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
@ -182,9 +156,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.shell.started", message),
data.on("session.agent.selected", message),
data.on("session.model.selected", message),
data.on("session.compaction.ended", (event) => {
if (event.data.reason !== "manual") message(event)
}),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
@ -224,20 +196,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
return rows
}
export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) {
const isInput = (message: SessionMessage) => inputs.has(message.id)
const pendingCompactions = messages.filter(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
export function reduceSessionRows(messages: SessionMessageInfo[]) {
return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!pending.has(message.id)) completePrevious(rows)
completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}

View file

@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
<pluginRuntime.Slot
name="sidebar_footer"
mode="single_winner"
session_id={props.sessionID}
directory={session()?.location.directory ?? ""}
>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>

View file

@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { lastAssistantWithUsage } from "../../util/session"
export function SubagentFooter() {
const route = useRouteData("session")
@ -22,17 +23,15 @@ export function SubagentFooter() {
const usage = createMemo(() => {
const current = session()
if (!current) return
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
if (!last) return
const tokens =
current.tokens.input +
current.tokens.output +
current.tokens.reasoning +
current.tokens.cache.read +
current.tokens.cache.write
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = current.cost
@ -83,10 +82,10 @@ export function SubagentFooter() {
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>

View file

@ -1,3 +1,14 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
return messages.findLast(
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
)
}

View file

@ -6,7 +6,7 @@ export function webSearchProviderLabel(provider: unknown) {
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
if (!("status" in state) || state.status === "pending") return {}
if (!("status" in state) || state.status === "streaming") return {}
if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
if (Array.isArray(state.structured)) return {}
return state.structured as Record<string, unknown>

View file

@ -93,7 +93,7 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
}
}
function durable(sessionID: string) {
function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } {
return { aggregateID: sessionID, seq: 0, version: 1 }
}

View file

@ -24,6 +24,12 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event)
events.emit({ ...event, location: { directory } })
}
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
function durable<const Version extends number>(
sessionID: string,
seq: number,
version: Version,
): { aggregateID: string; seq: number; version: Version }
function durable(sessionID: string, seq = 0, version = 1) {
return { aggregateID: sessionID, seq, version }
}
@ -108,6 +114,356 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("pages older messages through nested message state", async () => {
const events = createEventStream()
const sessionID = "ses_message_page"
const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = []
// Full first page (desc) so complete stays false until a short older page arrives.
const first = Array.from({ length: 50 }, (_, index) => {
const n = 51 - index
return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } }
})
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
pages.push({
limit: url.searchParams.get("limit"),
order: url.searchParams.get("order"),
cursor: url.searchParams.get("cursor"),
})
if (!url.searchParams.get("cursor"))
return json({
data: first,
cursor: { next: "cursor-older" },
})
return json({
data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }],
cursor: {},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.message.refresh(sessionID)
expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }])
expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id))
expect(data.session.message.cursor(sessionID)).toBe("cursor-older")
expect(data.session.message.complete(sessionID)).toBe(false)
expect(data.session.message.loading(sessionID)).toBe(false)
await data.session.message.more(sessionID)
expect(pages).toEqual([
{ limit: "50", order: "desc", cursor: null },
{ limit: "50", order: null, cursor: "cursor-older" },
])
expect(data.session.message.ids(sessionID)[0]).toBe("msg_1")
expect(data.session.message.ids(sessionID)).toHaveLength(51)
expect(data.session.message.cursor(sessionID)).toBeUndefined()
expect(data.session.message.complete(sessionID)).toBe(true)
await data.session.message.more(sessionID)
expect(pages).toHaveLength(2)
} finally {
app.renderer.destroy()
}
})
test("applies absolute usage events to session info", async () => {
const events = createEventStream()
const sessionID = "ses_usage_refresh"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Usage",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_usage_2",
created: 2,
type: "session.usage.updated",
data: {
sessionID,
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.get(sessionID)?.tokens).toEqual({
input: 5,
output: 2,
reasoning: 1,
cache: { read: 1, write: 1 },
})
emitEvent(events, {
id: "evt_usage_3",
created: 3,
type: "session.usage.updated",
data: {
sessionID,
cost: 1,
tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 1)
expect(data.session.get(sessionID)?.title).toBe("Usage")
emitEvent(events, {
id: "evt_usage_deleted",
created: 9,
type: "session.deleted",
durable: durable(sessionID, 9, 2),
data: { sessionID },
})
await wait(() => data.session.get(sessionID) === undefined)
} finally {
app.renderer.destroy()
}
})
test("truncates committed revert messages without changing lifetime usage", async () => {
const events = createEventStream()
const sessionID = "ses_revert_usage"
let cost = 0
let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname !== `/api/session/${sessionID}`) return
return json({
data: {
id: sessionID,
projectID: "proj_test",
cost,
tokens,
time: { created: 0, updated: 0 },
title: "Revert usage",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_revert_boundary_started",
created: 1,
type: "session.step.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.5
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_boundary_ended",
created: 2,
type: "session.step.ended",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
finish: "stop",
cost: 0.5,
tokens,
},
})
emitEvent(events, {
id: "evt_revert_boundary_usage",
created: 2,
type: "session.usage.updated",
data: { sessionID, cost, tokens },
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
emitEvent(events, {
id: "evt_revert_later_started",
created: 3,
type: "session.step.started",
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.75
tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_later_ended",
created: 4,
type: "session.step.ended",
durable: durable(sessionID, 4),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
finish: "stop",
cost: 0.25,
tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
emitEvent(events, {
id: "evt_revert_later_usage",
created: 4,
type: "session.usage.updated",
data: { sessionID, cost, tokens },
})
await wait(() => data.session.get(sessionID)?.cost === 0.75)
emitEvent(events, {
id: "evt_revert_staged",
created: 5,
type: "session.revert.staged",
durable: durable(sessionID, 5),
data: { sessionID, revert: { messageID: "msg_revert_later" } },
})
await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later")
emitEvent(events, {
id: "evt_revert_committed",
created: 6,
type: "session.revert.committed",
durable: durable(sessionID, 6),
data: { sessionID, to: "msg_revert_later" },
})
await wait(() => data.session.message.ids(sessionID).length === 1)
expect(data.session.get(sessionID)?.cost).toBe(0.75)
expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"])
expect(data.session.get(sessionID)?.revert).toBeUndefined()
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
} finally {
app.renderer.destroy()
}
})
test("updates session location when moved", async () => {
const events = createEventStream()
const destination = "/tmp/opencode-moved"
const calls = createFetch((url) => {
if (url.pathname === "/api/session/ses_test")
return json({
data: {
id: "ses_test",
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Test session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
data = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await data.session.refresh("ses_test")
emitEvent(events, {
id: "evt_moved_1",
created: 1,
type: "session.moved",
durable: durable("ses_test"),
data: {
sessionID: "ses_test",
location: { directory: destination },
subpath: "packages/cli",
},
})
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
} finally {
app.renderer.destroy()
}
})
test("restores running manual compaction before applying live deltas", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
@ -148,7 +504,11 @@ test("restores running manual compaction before applying live deltas", async ()
try {
await data.session.message.refresh("session-compaction")
expect(data.session.compaction("session-compaction")).toBe("Existing ")
expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({
type: "compaction",
status: "running",
summary: "Existing ",
})
emitEvent(events, {
id: "evt_compaction_delta",
@ -159,7 +519,7 @@ test("restores running manual compaction before applying live deltas", async ()
await wait(() => {
const message = data.session.message.get("session-compaction", "message-compaction")
return message?.type === "compaction" && message.summary === "Existing summary"
return message?.type === "compaction" && message.status === "running" && message.summary === "Existing summary"
})
} finally {
app.renderer.destroy()
@ -234,15 +594,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
emitEvent(events, {
id: "evt_execution_started_after_reconnect",
created: 1,
type: "session.execution.started",
durable: durable("session-new"),
data: { sessionID: "session-new" },
})
await wait(() => data.session.status("session-new") === "running")
resolveActive(json({ data: {} }))
resolveActive(json({ data: { "session-new": { type: "running" } } }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
@ -256,16 +608,18 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
}
})
test("completes exploration when a queued prompt is promoted", async () => {
test("keeps pending prompts out of history rows until promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
let data!: ReturnType<typeof useData>
function Probe() {
rows = createSessionRows(() => sessionID)
data = useData()
return <box />
}
@ -320,7 +674,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
delivery: "steer",
},
})
await wait(() => rows.at(-1)?.type === "message")
await wait(() => data.session.input.has(sessionID, "message-user"))
expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false)
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
emitEvent(events, {
@ -330,7 +685,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
durable: durable(sessionID, 3),
data: { sessionID, inputID: "message-user" },
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user"))
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
} finally {
app.renderer.destroy()
@ -455,8 +811,35 @@ test("connectedOnce is false until first connect and persists across disconnect"
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
let settled = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
if (url.pathname === "/api/session/session-live")
return json({
data: {
id: "session-live",
projectID: "proj_test",
cost: settled ? 0.75 : 0,
tokens: settled
? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }
: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Live session",
location: { directory },
},
})
if (url.pathname === "/api/session/session-failed")
return json({
data: {
id: "session-failed",
projectID: "proj_test",
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
time: { created: 0, updated: 0 },
title: "Failed session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
let rows!: SessionRow[]
@ -484,7 +867,9 @@ test("tracks session status from active sessions and execution events", async ()
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
await data.session.refresh("session-live")
settled = true
emitEvent(events, {
id: "evt_execution_started",
created: 0,
@ -510,30 +895,46 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_step_ended",
created: 0,
type: "session.step.ended",
durable: durable("session-live", 1, 2),
durable: durable("session-live", 1),
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
emitEvent(events, {
id: "evt_step_usage",
created: 0,
type: "session.usage.updated",
data: {
sessionID: "session-live",
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
await wait(() => {
const assistant = data.session.message.get("session-live", "message-live")
return assistant?.type === "assistant" && assistant.finish === "stop"
})
await wait(() => data.session.get("session-live")?.cost === 0.75)
expect(data.session.status("session-live")).toBe("running")
expect(data.session.get("session-live")).toMatchObject({
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
emitEvent(events, {
id: "evt_execution_succeeded",
created: 0,
type: "session.execution.succeeded",
durable: durable("session-live", 1, 3),
durable: durable("session-live", 1),
data: { sessionID: "session-live" },
})
await wait(() => data.session.status("session-live") === "idle")
await data.session.refresh("session-failed")
emitEvent(events, {
id: "evt_failed_execution_started",
created: 0,
@ -559,11 +960,23 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_step_failed",
created: 0,
type: "session.step.failed",
durable: durable("session-failed", 1, 2),
durable: durable("session-failed", 1),
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
error: { type: "provider.content-filter", message: "Provider blocked the response" },
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
})
emitEvent(events, {
id: "evt_failed_step_usage",
created: 0,
type: "session.usage.updated",
data: {
sessionID: "session-failed",
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
})
await wait(() => {
@ -574,13 +987,20 @@ test("tracks session status from active sessions and execution events", async ()
assistant.error?.type === "provider.content-filter"
)
})
await wait(() => data.session.get("session-failed")?.cost === 0.25)
expect(data.session.get("session-failed")?.tokens).toEqual({
input: 5,
output: 1,
reasoning: 1,
cache: { read: 1, write: 0 },
})
expect(data.session.status("session-failed")).toBe("running")
emitEvent(events, {
id: "evt_failed_execution_failed",
created: 0,
type: "session.execution.failed",
durable: durable("session-failed", 1, 3),
durable: durable("session-failed", 1),
data: {
sessionID: "session-failed",
error: { type: "provider.content-filter", message: "Provider blocked the response" },
@ -599,7 +1019,7 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_retry_step_started",
created: 0,
type: "session.step.started",
durable: durable("session-retry", 1, 2),
durable: durable("session-retry", 1),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
@ -611,7 +1031,7 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_retry_scheduled",
created: 0,
type: "session.retry.scheduled",
durable: durable("session-retry", 1, 3),
durable: durable("session-retry", 1),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
@ -629,7 +1049,7 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_retry_next_step",
created: 2_000,
type: "session.step.started",
durable: durable("session-retry", 1, 4),
durable: durable("session-retry", 1),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
@ -647,7 +1067,7 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_retry_scheduled_again",
created: 2_000,
type: "session.retry.scheduled",
durable: durable("session-retry", 1, 5),
durable: durable("session-retry", 1),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
@ -664,29 +1084,18 @@ test("tracks session status from active sessions and execution events", async ()
id: "evt_retry_interrupted",
created: 2_000,
type: "session.execution.interrupted",
durable: durable("session-retry", 1, 6),
durable: durable("session-retry", 1),
data: { sessionID: "session-retry", reason: "shutdown" },
})
await wait(() => data.session.status("session-retry") === "idle")
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
emitEvent(events, {
id: "evt_compaction_admitted",
created: 0,
type: "session.compaction.admitted",
durable: durable("session-manual", 1),
data: { sessionID: "session-manual", inputID: "message-compaction" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "queued"
})
emitEvent(events, {
id: "evt_manual_compaction_started",
created: 1,
type: "session.compaction.started",
durable: durable("session-manual", 2),
data: { sessionID: "session-manual", reason: "manual" },
data: { sessionID: "session-manual", reason: "manual", recent: "", inputID: "message-compaction" },
})
emitEvent(events, {
id: "evt_manual_compaction_delta",
@ -696,13 +1105,13 @@ test("tracks session status from active sessions and execution events", async ()
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.summary === "Streamed summary"
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
})
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
type: "session.compaction.ended",
durable: durable("session-manual", 3),
durable: durable("session-manual", 4),
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
})
await wait(() => {
@ -718,7 +1127,7 @@ test("tracks session status from active sessions and execution events", async ()
created: 0,
type: "session.compaction.started",
durable: durable("session-live", 2),
data: { sessionID: "session-live", reason: "auto" },
data: { sessionID: "session-live", reason: "auto", recent: "" },
})
emitEvent(events, {
id: "evt_compaction_delta_1",
@ -732,18 +1141,25 @@ test("tracks session status from active sessions and execution events", async ()
type: "session.compaction.delta",
data: { sessionID: "session-live", text: "summary" },
})
await wait(() => data.session.compaction("session-live") === "Live summary")
await wait(() => {
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
})
emitEvent(events, {
id: "evt_compaction_ended",
created: 0,
type: "session.compaction.ended",
durable: durable("session-live", 3),
durable: durable("session-live", 5),
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
})
await wait(() => data.session.compaction("session-live") === undefined)
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
await wait(() => {
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "completed"
})
expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({
type: "compaction",
status: "completed",
summary: "Live summary",
})
} finally {
@ -1092,6 +1508,52 @@ test("adds and dismisses permission requests from live events", async () => {
}
})
test("reconciles all pending permission requests when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] },
{ id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] },
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/permission/request") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old")
expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep")
requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }]
events.disconnect()
await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new")
expect(data.session.permission.list("ses_old")).toBeUndefined()
expect(data.session.permission.list("ses_keep")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("adds, dismisses, and refreshes form requests", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
@ -1162,6 +1624,52 @@ test("adds, dismisses, and refreshes form requests", async () => {
}
})
test("reconciles all pending form requests when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] },
{ id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" },
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }]
events.disconnect()
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
expect(data.session.form.list("ses_old")).toBeUndefined()
expect(data.session.form.list("ses_keep")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream()
const calls = createFetch((url) => {

View file

@ -122,6 +122,8 @@ describe("TUI inline tool wrapping", () => {
// Legacy tool names normalize to their renamed views.
expect(toolDisplay("bash")).toBe("shell")
expect(toolDisplay("task")).toBe("subagent")
expect(toolDisplay("apply_patch")).toBe("patch")
expect(toolDisplay("patch")).toBe("patch")
expect(toolDisplay("plugin_tool")).toBe("generic")
})

View file

@ -1,9 +1,9 @@
import { expect, test } from "bun:test"
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
import { reduceSessionRows } from "../../../src/routes/session/rows"
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
assistant("assistant-1", [
{ type: "text", text: "Looking" },
@ -35,7 +35,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
})
test("keeps non-exploration tools as individual part rows", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
@ -63,7 +63,7 @@ test("keeps non-exploration tools as individual part rows", () => {
})
test("assigns stable kind ordinals within an assistant message", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "text", text: "First" },
{ type: "reasoning", text: "Think" },
@ -81,7 +81,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
})
test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "reasoning", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
@ -112,7 +112,7 @@ test("completes exploration groups when another row follows", () => {
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
])
finished.finish = "stop"
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
finished,
@ -139,12 +139,11 @@ test("completes exploration groups when another row follows", () => {
})
test("hides synthetic messages without descriptions", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
id: "synthetic-1",
sessionID: "session-1",
text: "internal context",
time: { created: 2 },
},
@ -166,12 +165,11 @@ test("hides synthetic messages without descriptions", () => {
})
test("renders synthetic messages with descriptions", () => {
const messages: SessionMessage[] = [
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
id: "synthetic-1",
sessionID: "session-1",
text: "internal context",
description: "Explicit notice",
time: { created: 2 },
@ -209,31 +207,25 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
})
test("places a pending compaction barrier before every queued user message", () => {
const queued = (id: string, text: string, created: number): SessionMessage => ({
type: "user",
id,
text,
time: { created },
})
const messages: SessionMessage[] = [
queued("user-before", "Before", 1),
test("history reduce keeps chronological order without pending reordering", () => {
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Before", time: { created: 1 } },
{
type: "compaction",
id: "compaction",
status: "queued",
status: "completed",
reason: "manual",
summary: "",
summary: "done",
recent: "",
time: { created: 2 },
},
queued("user-after", "After", 3),
{ type: "user", id: "user-2", text: "After", time: { created: 3 } },
]
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "message", messageID: "compaction" },
{ type: "message", messageID: "user-before" },
{ type: "message", messageID: "user-after" },
{ type: "message", messageID: "user-2" },
])
})
@ -249,5 +241,5 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
}
function pending() {
return { status: "pending" as const, input: "" }
return { status: "streaming" as const, input: "" }
}

View file

@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({})
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
@ -101,6 +101,10 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { isDefaultTitle } from "../../src/util/session"
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
@ -7,4 +8,22 @@ describe("util.session", () => {
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("tracks usage across undo and redo boundaries", () => {
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
})
})

View file

@ -31,7 +31,7 @@ describe("toolDisplayMetadata", () => {
})
test("does not expose pending or malformed metadata", () => {
expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({})
expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({})
expect(toolDisplayMetadata({ status: "completed" })).toEqual({})
expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({})
expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({})