Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 6eb2042acd Merge remote-tracking branch 'origin/v2' into session-diff
# Conflicts:
#	packages/client/src/effect/api/api.ts
#	packages/core/src/session.ts
#	packages/core/test/git.test.ts
#	packages/protocol/src/groups/session.ts
#	packages/server/src/handlers/session-error.ts
#	packages/server/src/handlers/session.ts
2026-09-08 19:30:14 +05:30
f9bc2233dd fix(app): align desktop agent and model switching (#47286)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 18:16:08 +08:00
Simon Klee 7487999e06 tabs: add compact session tab rail (#47938) 2026-09-08 12:11:16 +02:00
Simon Klee 2eea36e731 mini: add more minimal output presets. (#47931) 2026-09-08 11:53:02 +02:00
Simon Klee 4fef8edbe8 mini: add clear-screen command (#47928) 2026-09-08 11:24:36 +02:00
Simon Klee 50c552f763 tui: add tool filtering option to Markdown exports (#47929) 2026-09-08 11:24:29 +02:00
Luke Parker a3d5923aca fix(session-ui): stop refetching missing shell output (#47926) 2026-09-08 09:12:15 +00:00
Luke Parker ea2c0184ce fix(app): release attachment blobs when no draft references them (#47922) 2026-09-08 09:11:59 +00:00
Luke Parker 09c318094c fix(app): bound terminal snapshot serialization on teardown (#47924) 2026-09-08 09:04:17 +00:00
Luke Parker 22a534a0bb fix(desktop): skip differential updates when the cache is stale (#47925) 2026-09-08 09:03:14 +00:00
Shoubhit Dash 54504ab3a5 fix(client): synthesize idle messages live
The solid data layer mirrors every projected marker message from its event so the in-memory transcript matches the server before the next read; do the same for the idle marker on execution succeeded, failed, and non-shutdown interrupted.
2026-09-07 23:57:17 +05:30
Shoubhit Dash cc5086d127 feat(session): add turn diff route
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
2026-09-07 22:08:19 +05:30
Shoubhit Dash b20482461c feat(session): record idle boundaries as messages
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
2026-09-07 22:00:36 +05:30
Shoubhit Dash 5b5368fe98 perf(core): batch snapshot tree diffs
Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
2026-09-07 21:53:19 +05:30
86 changed files with 4198 additions and 728 deletions
@@ -1,6 +1,25 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -342,6 +342,7 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -602,6 +602,7 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
+1
View File
@@ -39,6 +39,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
+1
View File
@@ -207,6 +207,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
+68 -38
View File
@@ -1,4 +1,5 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -9,6 +10,7 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -31,6 +33,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -43,15 +46,31 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { model?: ModelKey; variant?: string } | undefined
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -62,7 +81,8 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -74,7 +94,9 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent: recentModels,
list: models.list,
@@ -83,19 +105,24 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -104,38 +131,41 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
},
selected() {
return prompt.model.current()?.variant
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
},
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const variants = this.list()
@@ -143,13 +173,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
},
} satisfies ModelSelection
}
return selection
return selection satisfies ModelSelection
}
+152 -6
View File
@@ -91,6 +91,8 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -99,12 +101,16 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -122,6 +128,146 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+38 -23
View File
@@ -11,6 +11,7 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -88,7 +89,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -124,9 +125,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -315,8 +319,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -328,7 +334,33 @@ async function sendCommand(
})
}
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -336,24 +368,7 @@ async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
await applySelection(session, value.selection, track)
}
const admission = {
@@ -110,6 +110,7 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
@@ -0,0 +1,28 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
setStore("variant", { [key]: value ?? "default" })
return
}
setStore("variant", key, value)
setStore("variant", key, value ?? "default")
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
+144 -83
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,6 +16,7 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -24,11 +25,15 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -78,6 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -98,16 +104,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -176,13 +175,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -192,25 +195,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
})
},
move(direction: 1 | -1) {
@@ -230,8 +232,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -243,26 +252,41 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
}
const selected = () => scope()?.variant
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const snapshot = () => {
const model = current()
const selected = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...scope(),
agent: agent.current()?.name,
...next,
} satisfies State
@@ -274,22 +298,59 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const model = {
ready: models.ready,
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -298,21 +359,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
},
visible(item: ModelKey) {
return models.visible(item)
@@ -324,16 +382,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -341,21 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const items = this.list()
@@ -363,8 +409,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
@@ -383,20 +429,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
handoff.set(key, committed)
if (dir === sdk().directory) {
setSaved("session", session, next)
setSaved("session", session, committed)
}
setStore("promoting", next)
setStore("promoting", committed)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -412,4 +472,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
test("cycles from configured last variant to default", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
expect(value).toBeUndefined()
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,4 +83,22 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+5 -17
View File
@@ -16,6 +16,7 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -24,29 +25,16 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
}
@@ -230,3 +230,72 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+84 -21
View File
@@ -28,23 +28,77 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
const url = URL.createObjectURL(blob)
urls.set(id, url)
held.set(id, blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -60,24 +114,25 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver): DraftStore {
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = urls.get(id)
if (existing) return existing
const existing = retained.get(id)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
return { id, url: blobUrl(id, blob, grace) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -145,8 +200,8 @@ export function createDraftStore(driver: Driver): DraftStore {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -192,8 +247,7 @@ export function createDraftStore(driver: Driver): DraftStore {
remember(chunks, next, source.chunk)
return
}
held.set(next, blob)
blobUrl(next, blob)
blobUrl(next, blob, grace)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -225,14 +279,19 @@ export function createDraftStore(driver: Driver): DraftStore {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) return
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
}
return {
getItem: async (key) => {
@@ -241,6 +300,8 @@ export function createDraftStore(driver: Driver): DraftStore {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
@@ -248,6 +309,7 @@ export function createDraftStore(driver: Driver): DraftStore {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -360,7 +422,8 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
+2 -6
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,14 +62,10 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
})
createEffect(
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,8 +54,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
variant: { current: () => "high" },
},
},
{
@@ -77,8 +76,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
variant: { current: () => model.variant },
},
},
{
@@ -93,54 +91,26 @@ describe("syncPromptModel", () => {
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
syncPromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
set: (value) => calls.push(value),
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
})
})
@@ -12,10 +12,8 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -51,18 +49,3 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
@@ -63,6 +63,36 @@ describe("SerializeAddon", () => {
}
})
describe("scrollback option", () => {
test("reads only the requested tail and restores the cursor on its screen row", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
await writeAndWait(term, "\x1b[2A\x1b[3G")
expect(term.buffer.normal.length).toBe(30)
expect([term.buffer.normal.cursorX, term.buffer.normal.cursorY]).toEqual([2, 2])
const reads = spyOn(term.buffer.normal, "getLine")
const serialized = addon.serialize({ scrollback: 3 })
expect(new Set(reads.mock.calls.map((args) => args[0]))).toEqual(new Set([22, 23, 24, 25, 26, 27, 28, 29]))
reads.mockRestore()
const restored = createTerminal(20, 5)
await writeAndWait(restored.term, serialized)
expect(restored.term.getScrollbackLength()).toBe(3)
for (let row = 0; row < 8; row++) {
expect(restored.term.buffer.normal.getLine(row)?.translateToString(true)).toBe(`line ${22 + row}`)
}
expect([restored.term.buffer.normal.cursorX, restored.term.buffer.normal.cursorY]).toEqual([2, 2])
})
test("serializes the whole buffer when it has fewer rows than requested", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
expect(addon.serialize({ scrollback: 100 })).toBe(addon.serialize())
})
})
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
@@ -481,12 +481,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
// CUP addresses the screen and ghostty-web reports cursorY relative to the screen, so the
// serialized range start must not shift the row. The cursor line sits in the screen region
// at the bottom of the buffer, after any scrollback rows.
content += `\u001b[${this._buffer.cursorY + 1};${this._buffer.cursorX + 1}H`
const line = this._buffer.getLine(absoluteCursorRow)
const line = this._buffer.getLine(this._buffer.length - this._terminal.rows + this._buffer.cursorY)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
@@ -20,6 +20,12 @@ import { terminalWriter } from "@/session/terminal/writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
// Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the
// result is written to localStorage or desktop state for every terminal in the workspace.
// Persisting the most recent 2k scrollback rows keeps restore fidelity for the history users
// actually scroll back through while capping teardown cost and snapshot size; the live
// terminal keeps its full 10k scrollback while mounted.
const persistedScrollbackRows = 2_000
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
@@ -152,7 +158,7 @@ const persistTerminal = (input: {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
return input.addon.serialize({ scrollback: persistedScrollbackRows })
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""
@@ -0,0 +1,459 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
+11 -1
View File
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1133,6 +1142,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -594,6 +596,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -744,6 +757,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -844,6 +846,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2177,6 +2185,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3122,6 +3131,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3413,6 +3429,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3704,6 +3727,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4193,6 +4223,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
+12
View File
@@ -1032,6 +1032,18 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+24
View File
@@ -57,8 +57,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -113,6 +116,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -142,6 +146,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -230,6 +241,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -362,6 +374,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -450,6 +473,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+20 -3
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -123,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
@@ -9,7 +9,8 @@ afterEach(async () => {
})
// Drives the updater the way the app does: start or check, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
// operations in order; downloads record whether a differential download was allowed and installs record the staged
// version they would apply.
function setup(input?: {
currentVersion?: string
ready?: { version: string }
@@ -29,10 +30,11 @@ function setup(input?: {
},
catch: (error) => error,
}),
stageUpdate: Effect.tryPromise(async () => {
calls.push("download")
await input?.stage?.()
}),
stageUpdate: (options) =>
Effect.tryPromise(async () => {
calls.push(options.differential ? "download" : "download:full")
await input?.stage?.()
}),
installAndRestart: Effect.suspend(() => {
calls.push(`install:${ready?.version}`)
return Effect.tryPromise({
@@ -76,6 +78,7 @@ describe("updater", () => {
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
@@ -90,15 +93,37 @@ describe("updater", () => {
expect(app.getReady()).toBeUndefined()
})
test("revalidates a persisted target through the updater cache on launch", async () => {
test("revalidates a persisted target through the updater cache on launch without a differential download", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.calls).toEqual(["check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("keeps differential downloads after the persisted target was installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" }, latest: () => "3.0.0" })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
expect(app.getReady()).toEqual({ version: "3.0.0" })
})
test("downloads newer releases in full once one is staged", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.updater.start()
latest = "3.0.0"
await app.updater.check()
expect(app.calls).toEqual(["check", "download", "check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
})
test("concurrent checks share one platform check", async () => {
const app = setup()
@@ -140,7 +165,7 @@ describe("updater", () => {
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
@@ -220,7 +245,7 @@ describe("updater", () => {
await refresh
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
})
test("returns to ready after a failed installation and allows a retry", async () => {
+21 -5
View File
@@ -8,7 +8,7 @@ import { emitIpcEvent } from "../ipc-events"
export type Platform = {
readonly checkForUpdate: Effect.Effect<string | undefined, unknown>
readonly stageUpdate: Effect.Effect<unknown, unknown>
readonly stageUpdate: (options: { readonly differential: boolean }) => Effect.Effect<unknown, unknown>
readonly installAndRestart: Effect.Effect<never, unknown>
readonly dispose: () => void
}
@@ -55,6 +55,22 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
listeners.forEach((listener) => listener(state))
return state
}
// electron-updater builds NSIS deltas against the installer of the running version but reads the "old" blockmap from
// the last download. Once a release is staged without installing, the two no longer match and every later delta fails
// its checksum before falling back to a full download, so remember which release the cache holds and skip the attempt.
let downloaded: string | undefined
const stage = (platform: Platform, version: string) =>
Effect.gen(function* () {
if (downloaded)
yield* Effect.logInfo("skipping differential download, updater cache is stale", {
current: dependencies.currentVersion,
staged: downloaded,
version,
})
yield* platform.stageUpdate({ differential: !downloaded })
downloaded = version
yield* dependencies.persistence.set({ version })
})
const findAndStage = (platform: Platform) =>
Effect.gen(function* () {
yield* Effect.sync(() => transition({ status: "checking" }))
@@ -64,8 +80,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
yield* stage(platform, version)
return transition({ status: "ready", version })
}).pipe(
Effect.catch((error) =>
@@ -78,8 +93,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
Effect.gen(function* () {
const version = yield* platform.checkForUpdate
if (!version || version === staged || version === dependencies.currentVersion) return state
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
yield* stage(platform, version)
return transition({ status: installing ? "installing" : "ready", version })
}).pipe(
Effect.catch((error) =>
@@ -137,6 +151,8 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
const start = Effect.gen(function* () {
const ready = yield* dependencies.persistence.get
if (ready?.version === dependencies.currentVersion) yield* dependencies.persistence.clear
// Any other persisted target was downloaded by an earlier launch and never installed, so its blockmap is cached.
if (ready && ready.version !== dependencies.currentVersion) downloaded = ready.version
yield* check
})
const unsubscribe = (id: number) => {
@@ -37,16 +37,21 @@ export const make = Effect.gen(function* () {
},
catch: (error) => error,
}),
stageUpdate: stageUpdate(),
stageUpdate,
installAndRestart,
dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit),
} satisfies Platform
})
function stageUpdate() {
function stageUpdate(options: { readonly differential: boolean }) {
if (process.platform !== "darwin")
return Effect.tryPromise({
try: () => updateClient.downloadUpdate(),
try: () => {
// Only the NSIS cache goes stale: macOS refreshes its cached zip with every download and AppImage reads the
// blockmap embedded in the running file.
updateClient.disableDifferentialDownload = process.platform === "win32" && !options.differential
return updateClient.downloadUpdate()
},
catch: (error) => error,
}).pipe(Effect.asVoid)
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+26
View File
@@ -30,6 +30,7 @@ import { Model } from "@opencode/schema/model"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -521,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+14
View File
@@ -272,6 +272,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -283,6 +295,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -295,4 +308,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+33 -62
View File
@@ -17,10 +17,9 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, missingSession } from "./session-error"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -212,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -448,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -481,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -527,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
@@ -642,15 +621,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
const message = yield* session.updateMessage({ ...ctx.params, content: ctx.payload.content }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag(
"Session.MessageNotAssistantError",
+98
View File
@@ -0,0 +1,98 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "messageID" },
})
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
@@ -0,0 +1,128 @@
import { describe, expect, test, vi } from "bun:test"
import type { ShellOutputInput, ShellOutputOutput } from "@opencode/client/promise"
import { followShellOutput, SHELL_OUTPUT_TAIL_BYTES } from "./shell-output"
const location = { directory: "/repo", project: { id: "project", directory: "/repo", canonical: "/repo" } }
// Serves the current text from the requested cursor, at most one page per request.
function server(text: () => string, page = Infinity) {
const cursors: number[] = []
return {
cursors,
load: (input: ShellOutputInput): Promise<ShellOutputOutput> => {
const cursor = input.cursor ?? 0
cursors.push(cursor)
const full = text()
const output = full.slice(cursor, cursor + page)
return Promise.resolve({
location,
data: { output, cursor: cursor + output.length, size: full.length, truncated: false },
})
},
}
}
// Reads settle within a few microtasks; timers are faked so this never waits on the clock.
async function flush() {
for (let i = 0; i < 20; i++) await Promise.resolve()
}
describe("followShellOutput", () => {
test("stops polling a missing shell and never requests it again", async () => {
vi.useFakeTimers()
try {
const cursors: number[] = []
const load = (input: ShellOutputInput): Promise<ShellOutputOutput> => {
cursors.push(input.cursor ?? 0)
return Promise.reject({ _tag: "ShellNotFoundError", id: input.id, message: "Shell command not found" })
}
const follow = () =>
followShellOutput({ id: "shell_missing", directory: "/repo", running: true, load, onOutput() {} })
const stop = follow()
await flush()
expect(cursors).toEqual([0])
vi.advanceTimersByTime(10_000)
await flush()
expect(cursors).toEqual([0])
stop()
const remounted = follow()
await flush()
vi.advanceTimersByTime(10_000)
remounted()
expect(cursors).toEqual([0])
} finally {
vi.useRealTimers()
}
})
test("polls a running shell once per second and resumes from the cached cursor after a remount", async () => {
vi.useFakeTimers()
try {
let text = "hello\n"
const shell = server(() => text)
const outputs: string[] = []
const follow = (running: boolean) =>
followShellOutput({
id: "shell_live",
directory: "/repo",
running,
load: shell.load,
onOutput: (output) => outputs.push(output),
})
const first = follow(true)
await flush()
expect(outputs.at(-1)).toBe("hello\n")
text += "world\n"
vi.advanceTimersByTime(1_000)
await flush()
expect(shell.cursors).toEqual([0, 6])
expect(outputs.at(-1)).toBe("hello\nworld\n")
first()
text += "done\n"
const second = follow(true)
await flush()
expect(shell.cursors).toEqual([0, 6, 12])
expect(outputs.at(-1)).toBe("hello\nworld\ndone\n")
second()
const exited = follow(false)
await flush()
exited()
expect(shell.cursors).toEqual([0, 6, 12, 17])
outputs.length = 0
follow(false)()
await flush()
expect(shell.cursors).toEqual([0, 6, 12, 17])
expect(outputs).toEqual(["hello\nworld\ndone\n"])
// The running-shell inventory can arrive late; a complete shell believed to be live is re-read from its cursor.
follow(true)()
expect(shell.cursors).toEqual([0, 6, 12, 17, 17])
} finally {
vi.useRealTimers()
}
})
test("keeps only the most recent tail of a large output", async () => {
const shell = server(() => "a".repeat(SHELL_OUTPUT_TAIL_BYTES) + "tail", SHELL_OUTPUT_TAIL_BYTES)
const outputs: string[] = []
followShellOutput({
id: "shell_large",
directory: "/repo",
running: false,
load: shell.load,
onOutput: (output) => outputs.push(output),
})
await flush()
expect(shell.cursors).toEqual([0, SHELL_OUTPUT_TAIL_BYTES])
expect(outputs.at(-1)?.length).toBe(SHELL_OUTPUT_TAIL_BYTES)
expect(outputs.at(-1)?.endsWith("a".repeat(8) + "tail")).toBe(true)
})
})
@@ -0,0 +1,74 @@
import { isShellNotFoundError, type ShellOutputInput, type ShellOutputOutput } from "@opencode/client/promise"
// Same page size as the TUI shell output viewer; only this much recent output is retained and rendered.
export const SHELL_OUTPUT_TAIL_BYTES = 64 * 1024
const PROGRESS_LIMIT = 32
type Progress = { cursor: number; output: string; state: "partial" | "complete" | "missing" }
// Virtualized timelines remount shell tools frequently. Progress is remembered per shell so a
// remount resumes from its cursor instead of re-reading from zero, and so a shell the server no
// longer knows is never requested again.
const progress = new Map<string, Progress>()
function remember(id: string, entry: Progress) {
progress.delete(id)
progress.set(id, entry)
if (progress.size <= PROGRESS_LIMIT) return
const oldest = progress.keys().next().value
if (oldest !== undefined) progress.delete(oldest)
}
export function followShellOutput(input: {
id: string
directory: string
running: boolean
load: (input: ShellOutputInput) => Promise<ShellOutputOutput>
onOutput: (output: string) => void
}) {
const cached = progress.get(input.id)
if (cached) {
remember(input.id, cached)
input.onOutput(cached.output)
}
// A missing shell never comes back. A complete one is only re-read while the shell is believed to be
// running, because the running-shell inventory can arrive after history has already rendered.
if (cached?.state === "missing" || (cached?.state === "complete" && !input.running)) return () => {}
let cursor = cached?.cursor ?? 0
let text = cached?.output ?? ""
let loading = false
let disposed = false
const read = async () => {
if (loading) return
loading = true
while (true) {
const page = await input.load({ id: input.id, location: { directory: input.directory }, cursor }).then(
(response) => response.data,
(cause: unknown) => (isShellNotFoundError(cause) ? "missing" : undefined),
)
if (disposed) break
if (page === "missing") {
// The server drops exited shells past its retention limit; stop asking for this one.
remember(input.id, { cursor, output: text, state: "missing" })
clearInterval(interval)
break
}
if (!page) break
const advanced = page.cursor > cursor
cursor = Math.max(cursor, page.cursor)
text = (text + page.output).slice(-SHELL_OUTPUT_TAIL_BYTES)
const complete = !input.running && cursor >= page.size
remember(input.id, { cursor, output: text, state: complete ? "complete" : "partial" })
input.onOutput(text)
if (input.running || complete || !advanced) break
}
loading = false
}
void read()
// Refresh the final snapshot on exit, but poll only while the shell is live.
const interval = input.running ? setInterval(() => void read(), 1_000) : undefined
return () => {
disposed = true
clearInterval(interval)
}
}
@@ -51,6 +51,7 @@ import {
executeToolFailed,
} from "../message/current-tool-state"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
import { followShellOutput } from "./shell-output"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
@@ -1629,33 +1630,9 @@ ToolRegistry.register({
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !shellOutput) return
const directory = data.directory
const running = pending()
let cursor = 0
let loading = false
let disposed = false
const load = async () => {
if (loading) return
loading = true
do {
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
if (disposed || !response) break
setStreamed((output) => (cursor === 0 ? response.data.output : output + response.data.output))
if (response.data.cursor <= cursor) break
cursor = response.data.cursor
if (running || cursor >= response.data.size) break
} while (!disposed)
loading = false
}
void load()
// Refresh the final snapshot on exit, but poll only while the shell is live.
const interval = running ? setInterval(() => void load(), 1_000) : undefined
onCleanup(() => {
disposed = true
clearInterval(interval)
})
const load = data.shellOutput
if (typeof id !== "string" || !load) return
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
})
const command = () => {
if (typeof props.input.command === "string") return props.input.command
+1 -1
View File
@@ -579,7 +579,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.size())
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
@@ -0,0 +1,94 @@
import { createMemo, createSignal } from "solid-js"
import type { RGBA } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import type { SessionTabsController } from "./session-tabs"
export function SessionTabsRailControls(props: {
width: number
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
)
let pressed = false
const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list"))
return (
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
pressed = event.button === 0
event.stopPropagation()
}}
onMouseUp={(event) => {
event.stopPropagation()
if (event.button !== 0 || !pressed) return
pressed = false
search()
}}
onMouseDragEnd={() => (pressed = false)}
>
<text width={1} height={1} fg={theme.text.action.secondary.default} selectable={false} wrapMode="none">
</text>
</box>
<text
position="absolute"
top={1}
left={0}
width={props.width}
height={1}
zIndex={2}
fg={hoverColor()}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
selectable={false}
>
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
</text>
</box>
)
}
export function SessionTabHalfRow(props: {
top: number
edge: "top" | "bottom"
width: number
color: RGBA
background: RGBA
}) {
return (
<text
position="absolute"
top={props.top}
left={0}
width={props.width}
height={1}
zIndex={1}
fg={props.color}
bg={props.background}
selectable={false}
wrapMode="none"
>
{(props.edge === "top" ? "▄" : "▀").repeat(props.width)}
</text>
)
}
+394 -200
View File
@@ -1,4 +1,11 @@
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
BoxRenderable,
CliRenderEvents,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type MouseEvent,
} from "@opentui/core"
import {
For,
Index,
@@ -12,7 +19,7 @@ import {
onCleanup,
untrack,
} from "solid-js"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
@@ -33,7 +40,7 @@ import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { SESSION_SIDEBAR_WIDTH, SESSION_TABS_COMPACT_BREAKPOINT } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
@@ -41,6 +48,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import { SessionTabsRailControls, SessionTabHalfRow } from "./session-tabs-rail"
import "./title-shimmer"
registerOpencodeSpinner()
@@ -105,6 +113,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
detail?: (sessionID: string) => string | undefined
isPreview?: (sessionID: string) => boolean
promote?: (sessionID: string) => void
rename?: (sessionID: string) => void
search?: () => void
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
@@ -120,13 +130,16 @@ function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof us
function TabIndicator(props: {
status: SessionTabsStatus
label: string
idleLabel?: string
width: number
centered?: boolean
color: RGBA
unreadColor: RGBA
backgroundColor: RGBA
flashColor: RGBA
animations: boolean
numbers: boolean
selected?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
attributes?: number
@@ -146,7 +159,7 @@ function TabIndicator(props: {
})
const fading = () => !props.status.unread && fade.value().opacity > 0
const color = () => {
if (props.numbers) return props.color
if (props.numbers || props.selected) return props.color
if (unread()) return props.unreadColor
if (!fading()) return props.color
const opacity = fade.value().opacity
@@ -162,10 +175,17 @@ function TabIndicator(props: {
if (runs()) return spinner().frames[0]
if (props.label === "+") return "+"
if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
return ""
return props.idleLabel ?? ""
}
return (
<box width={props.width + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end" paddingRight={1}>
<box
width={props.width + (props.centered ? 0 : 1)}
height={props.centered ? 1 : undefined}
flexShrink={0}
flexDirection="row"
justifyContent={props.centered ? "center" : "flex-end"}
paddingRight={props.centered ? 0 : 1}
>
<Show
when={runs() && props.animations && !props.numbers}
fallback={
@@ -384,7 +404,8 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, title),
run: () =>
props.tabs.rename ? props.tabs.rename(sessionID) : DialogSessionRename.show(dialog, sessionID, title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
@@ -472,6 +493,7 @@ export function SessionTabs(
animations?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
indicators?: "status" | "numbers"
orientation?: "horizontal" | "vertical"
width?: number
} = {},
@@ -486,7 +508,7 @@ export function SessionTabs(
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={config.tabs.indicators === "numbers"}
numbers={(props.indicators ?? config.tabs.indicators) === "numbers"}
width={props.width}
/>
</Match>
@@ -496,7 +518,7 @@ export function SessionTabs(
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={config.tabs.indicators === "numbers"}
numbers={(props.indicators ?? config.tabs.indicators) === "numbers"}
/>
</Match>
</Switch>
@@ -511,13 +533,18 @@ function VerticalSessionTabs(props: {
unreadMarker?: TabUnreadMarker
width?: number
}) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const data = useData()
const tabs: SessionTabsController = props.controller ?? useSessionTabs()
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme("elevated")
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
@@ -526,6 +553,26 @@ function VerticalSessionTabs(props: {
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
createEffect(() => {
compact()
untrack(marquee.reset)
})
const [hoverY, setHoverY] = createSignal(0)
const [scrollTop, setScrollTop] = createSignal(0)
const detail = (sessionID: string) => {
const fixture = tabs.detail?.(sessionID)
if (fixture !== undefined) return fixture
const session = data?.session.get(sessionID)
const project = session ? data?.project.get(session.projectID) : undefined
const vcs = session ? data?.location.vcs.info(session.location) : undefined
const location = session ? data?.location.info(session.location) : undefined
return sessionTabDetail(
projectName(project, session?.location.directory) ?? "",
vcs?.branch.current,
vcs?.branch.default,
!!location && location.project.directory !== location.project.canonical,
)
}
const handleClick = createPreviewDoubleClick(tabs)
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
@@ -539,6 +586,16 @@ function VerticalSessionTabs(props: {
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
const highlightColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
)
const highlighted = (sessionID: string | undefined) =>
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
const addHighlighted = () => newTab() || addHovered()
const belowHighlighted = createMemo(() => {
const tab = items()[Math.floor(scrollTop() / stride())]
return tab ? highlighted(tab.sessionID) : addHighlighted()
})
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
@@ -565,6 +622,8 @@ function VerticalSessionTabs(props: {
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
const updateScroll = () => setScrollTop(scroll?.scrollTop ?? 0)
onCleanup(() => scroll?.verticalScrollBar.off("change", updateScroll))
let didDrag = false
let addPressed = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
@@ -579,15 +638,22 @@ function VerticalSessionTabs(props: {
createEffect(() => {
if (!scroll) return
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
const index = items().findIndex((tab) => tab.sessionID === activeID())
dimensions()
const index = newTab() ? items().length : items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + 2 - scroll.viewport.height)
const top = index * stride()
const height = compact() ? 3 : newTab() ? 1 : 2
// Scroll after layout: newly opened tabs do not contribute to the scroll range yet.
const reveal = () => {
if (!scroll) return
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + height > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + height - scroll.viewport.height)
}
}
renderer.once(CliRenderEvents.FRAME, reveal)
renderer.requestRender()
onCleanup(() => renderer.off(CliRenderEvents.FRAME, reveal))
})
const release = () => {
@@ -607,7 +673,13 @@ function VerticalSessionTabs(props: {
didDrag = true
const target = Math.max(
0,
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
Math.min(
tabs.tabs().length - 1,
Math.floor(
(event.y - (scroll?.viewport.screenY ?? rail.screenY + 1) - (compact() ? 1 : 0) + (scroll?.scrollTop ?? 0)) /
stride(),
),
),
)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
@@ -634,21 +706,31 @@ function VerticalSessionTabs(props: {
onMouseDrag={drag}
onMouseDragEnd={release}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
<Show when={compact()}>
<SessionTabsRailControls width={width()} tabs={tabs} belowHighlighted={belowHighlighted()} />
</Show>
<scrollbox
ref={(element) => {
scroll = element
scroll.verticalScrollBar.on("change", updateScroll)
updateScroll()
}}
flexGrow={1}
minHeight={0}
backgroundColor={theme.background.default}
scrollbarOptions={{ visible: false }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
<For each={items()}>
{(tab, index) => {
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const session = createMemo(() => data?.session.get(tab.sessionID))
const numberWidth = () => Math.max(2, String(items().length).length)
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const prefixWidth = () => numberWidth() + 1
const restingTitleWidth = () => Math.max(1, width() - prefixWidth() - 1)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
@@ -667,25 +749,15 @@ function VerticalSessionTabs(props: {
.join(""),
)
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
const location = value ? data.location.info(value.location) : undefined
const worktree = !!location && location.project.directory !== location.project.canonical
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const tabDetail = createMemo(() => detail(tab.sessionID))
const visibleDetail = createMemo(() => Locale.takeWidth(tabDetail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
if (selected() && !compact()) return theme.background.action.primary.selected
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
})
@@ -790,12 +862,15 @@ function VerticalSessionTabs(props: {
}
return (
<box
height={2}
height={compact() ? 1 : 2}
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOver={(event) => {
setHoverY(event.y)
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
}}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
@@ -822,85 +897,52 @@ function VerticalSessionTabs(props: {
}
didDrag = false
handleClick(tab.sessionID)
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
setDragging(tab.sessionID)
}}
>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
outerGlow={previousGlows()}
color={separatorLowerPulseColor()}
width={indicatorWidth}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={pulseColor()}
width={indicatorWidth}
glowColor={glowColor()}
flashColor={flashColor()}
flashTail={8}
completionColor={glowColor()}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<Show when={compact()}>
<Show when={highlighted(tab.sessionID)}>
<SessionTabHalfRow
top={-1}
edge="top"
width={width()}
color={pulseBackground()}
background={
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={pulseBackground()}
background={
(
index() === items().length - 1
? addHighlighted()
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: theme.background.default
}
/>
</Show>
<box height={1} flexDirection="row" justifyContent="center">
<TabIndicator
centered
selected={selected()}
width={width()}
status={status()}
label={sessionTabNumberLabel(index())}
width={numberWidth()}
color={numberColor()}
idleLabel={Locale.graphemes(title().trimStart())[0] ?? "U"}
color={
selected()
? theme.text.default
: props.numbers
? numberColor()
: (tabFeedbackColor(status(), theme) ?? (runs() ? activeNumber() : foreground()))
}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
@@ -908,88 +950,179 @@ function VerticalSessionTabs(props: {
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0)
}
>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
fallback={visibleTitle()}
>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</title_shimmer>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "✕" : ""}
</text>
/>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
</Show>
<Show when={!compact()}>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
color={detailPulseColor()}
outerGlow={previousGlows()}
color={separatorLowerPulseColor()}
width={indicatorWidth}
glowColor={detailGlowColor()}
glowTail={10}
flashColor={detailFlashColor()}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
completionColor={detailGlowColor()}
backgroundColor={pulseBackground()}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={pulseColor()}
width={indicatorWidth}
glowColor={glowColor()}
flashColor={flashColor()}
flashTail={8}
completionColor={glowColor()}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<TabIndicator
status={status()}
label={sessionTabNumberLabel(index())}
width={numberWidth()}
color={numberColor()}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
fallback={visibleTitle()}
>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</title_shimmer>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "✕" : ""}
</text>
</box>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={detailPulseColor()}
width={indicatorWidth}
glowColor={detailGlowColor()}
glowTail={10}
flashColor={detailFlashColor()}
flashTail={8}
completionColor={detailGlowColor()}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={prefixWidth()} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
</box>
</box>
</Show>
</box>
)
}}
@@ -1002,11 +1135,13 @@ function VerticalSessionTabs(props: {
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
paddingLeft={compact() ? 0 : 1}
justifyContent={compact() ? "center" : "flex-start"}
alignItems="center"
backgroundColor={
newTab()
newTab() && !compact()
? theme.background.action.primary.selected
: addHovered()
: addHovered() || (compact() && newTab())
? theme.background.action.primary.hovered
: theme.background.default
}
@@ -1031,23 +1166,41 @@ function VerticalSessionTabs(props: {
}}
onMouseDragEnd={() => (addPressed = false)}
>
<Show when={compact() && addHighlighted()}>
<SessionTabHalfRow
top={-1}
edge="top"
width={width()}
color={highlightColor()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={theme.background.default}
/>
</Show>
<text
width={2}
width={compact() ? 1 : 2}
fg={newTab() || addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<Show when={!compact()}>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
</Show>
<Show when={newTab() && !compact()}>
<text
position="absolute"
right={1}
@@ -1070,6 +1223,47 @@ function VerticalSessionTabs(props: {
</Show>
</box>
</scrollbox>
<Show when={compact() && !dragging() && !contextMenu() && hovered()}>
{(sessionID) => (
<box
position="absolute"
left={width()}
top={Math.max(0, Math.min(hoverY() - 1, dimensions().height - 4) - (rail?.screenY ?? 0))}
width={tooltipWidth()}
height={4}
paddingY={1}
zIndex={2000}
>
<SessionTabHalfRow
top={0}
edge="top"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
/>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
{Locale.truncateWidth(
data?.session.get(sessionID())?.title ??
items().find((tab) => tab.sessionID === sessionID())?.title ??
"Untitled session",
tooltipWidth() - 2,
)}
</text>
<text fg={theme.text.subdued} wrapMode="none" selectable={false}>
{Locale.takeWidth(detail(sessionID()), tooltipWidth() - 2)}
</text>
</box>
<SessionTabHalfRow
top={3}
edge="bottom"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
/>
</box>
)}
</Show>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
+3
View File
@@ -191,6 +191,9 @@ export const Info = Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning",
}),
tools: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide tool calls and the assistant text that precedes them",
}),
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide raw shell tool output",
}),
+1
View File
@@ -46,6 +46,7 @@ export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
"app.clear": keybind("ctrl+l", "Clear the screen in mini"),
"app.debug": keybind("none", "Toggle debug panel"),
"app.console": keybind("none", "Toggle console"),
"app.scrap": keybind("none", "Open scrap screen"),
@@ -1,6 +1,6 @@
import { Plugin } from "@opencode/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For } from "solid-js"
import { batch, createSignal, For, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import {
EMPTY_SESSION_TAB_STATUS,
@@ -13,6 +13,18 @@ import {
} from "../../../component/session-tabs"
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
import { StoryFooter } from "./footer"
import { DialogPrompt } from "../../../ui/dialog-prompt"
import { useDialog } from "../../../ui/dialog"
import { DialogSelect } from "../../../ui/dialog-select"
import {
clampSessionTabsWidth,
sessionTabsFitVertically,
SESSION_SIDEBAR_WIDTH,
SESSION_TABS_COMPACT_BREAKPOINT,
SESSION_TABS_COMPACT_WIDTH,
} from "../../../ui/layout"
import { createPaneResize } from "../../../ui/pane-resize"
import { PaneResizeHandle } from "../../../ui/pane-resize-handle"
import type { Story } from "./index"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
@@ -54,6 +66,7 @@ const TRANSCRIPT_FILES = [
function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const dialog = useDialog()
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({
items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })),
@@ -65,6 +78,18 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error")
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>(FIXTURE_STATUSES)
const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical")
const [width, setWidth] = createSignal(SESSION_SIDEBAR_WIDTH)
const resize = createPaneResize({
value: width,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
clamp: (width) => clampSessionTabsWidth(width, dimensions().width),
fromMouse: (event) => event.x + 1,
contains: (event, width) => event.x >= width - 1 && event.x <= width,
onCommit: setWidth,
})
const vertical = () => orientation() === "vertical" && sessionTabsFitVertically(dimensions().width, resize.size())
const [indicators, setIndicators] = createSignal<"status" | "numbers">("status")
const railCompact = () => resize.size() < SESSION_TABS_COMPACT_BREAKPOINT
const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[]
const [spinner, setSpinner] = createSignal<TabSpinner>("dots")
const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[]
@@ -116,6 +141,22 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
tabs,
current: active,
add: addTab,
search() {
dialog.replace(() => (
<DialogSelect
title="Sessions (fixture)"
current={active()}
options={FIXTURE_TABS.map((tab) => ({ title: tab.title, value: tab.sessionID, description: tab.project }))}
onSelect={(option) => {
if (!tabs().some((tab) => tab.sessionID === option.value)) {
setItems([...tabs(), { ...FIXTURE_TABS.find((tab) => tab.sessionID === option.value)! }])
}
select(option.value)
dialog.clear()
}}
/>
))
},
detail(sessionID) {
return FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)?.project
},
@@ -123,6 +164,19 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
},
select,
rename(sessionID: string) {
dialog.replace(() => (
<DialogPrompt
title="Rename fixture tab"
value={tabs().find((tab) => tab.sessionID === sessionID)?.title}
onConfirm={(title) => {
if (!title.trim()) return
setTabStore("items", (tab) => tab.sessionID === sessionID, "title", title.trim())
dialog.clear()
}}
/>
))
},
move(sessionID: string, index: number) {
const next = moveSessionTab(tabs(), sessionID, index)
if (next === tabs()) return
@@ -267,6 +321,8 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setMarker("small-dot")
setAnimations(true)
setOrientation("vertical")
setWidth(SESSION_SIDEBAR_WIDTH)
setIndicators("status")
})
setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle")
}
@@ -395,8 +451,30 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
run: () => setMarker((value) => markers[(markers.indexOf(value) + 1) % markers.length]),
},
{ bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
{
bind: "g",
title: "Toggle status icons or numbers",
group: "Storybook",
run: () => setIndicators((value) => (value === "status" ? "numbers" : "status")),
},
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
bind: "b",
title: "Switch minimum or default width",
group: "Storybook",
run: () => setWidth(railCompact() ? SESSION_SIDEBAR_WIDTH : SESSION_TABS_COMPACT_WIDTH),
},
{
bind: "n",
title: "Cycle tab count",
group: "Storybook",
run() {
const count = tabs().length < 6 ? 6 : tabs().length < 12 ? 12 : 3
setItems(FIXTURE_TABS.slice(0, count).map((tab) => ({ ...tab })))
if (!tabs().some((tab) => tab.sessionID === active())) setActive("fixture-1")
},
},
{
bind: "o",
title: "Toggle tab orientation",
@@ -417,15 +495,24 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
flexDirection="column"
backgroundColor={theme.background.default}
>
<box flexGrow={1} flexDirection={orientation() === "vertical" ? "row" : "column"}>
<box
flexGrow={1}
minHeight={0}
flexDirection={vertical() ? "row" : "column"}
onMouseDrag={resize.onMouseDrag}
onMouseDragEnd={resize.onMouseDragEnd}
onMouseUp={resize.onMouseUp}
>
<SessionTabs
controller={controller}
orientation={orientation()}
orientation={vertical() ? "vertical" : "horizontal"}
width={resize.size()}
spinner={spinner()}
unreadMarker={marker()}
animations={animations()}
indicators={indicators()}
/>
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<box flexGrow={1} minWidth={0} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
@@ -434,19 +521,25 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
)}
</For>
</box>
<Show when={vertical()}>
<PaneResizeHandle resize={resize} left={resize.size() - 1} />
</Show>
</box>
<StoryFooter
context={props.context}
title="storybook / tabs"
details={[
orientation() === "vertical" ? "left rail" : "top strip",
vertical() ? `${railCompact() ? "compact rail" : "expanded rail"} · ${resize.size()} cols` : "top strip",
spinner(),
indicators(),
`${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
animations() ? "animated" : "still",
]}
status={stateSummary()}
message={lastEvent()}
controls={[
{ shortcut: "b", label: "min/default width" },
{ shortcut: "n", label: "3/6/12 tabs" },
{ shortcut: "s", label: "work" },
{ shortcut: "space/e", label: "random work" },
{ shortcut: "p", label: "prompt" },
@@ -456,10 +549,12 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ shortcut: "f/x", label: "complete/fail" },
{ shortcut: "t/d", label: "add/close" },
{ shortcut: "c", label: "spinner" },
{ shortcut: "g", label: "status/numbers" },
{ shortcut: "u", label: "unread marker" },
{ shortcut: "m", label: "motion" },
{ shortcut: "↑/↓", label: "select" },
{ shortcut: "o", label: "layout" },
{ shortcut: "drag edge", label: "resize / double-click reset" },
{ shortcut: "r", label: "reset idle" },
{ shortcut: "v", label: "all states" },
{ shortcut: "esc", label: "back" },
+49 -14
View File
@@ -27,6 +27,7 @@ import type {
RunInput,
RunProvider,
} from "./types"
import { matchMiniVerbosity, verbosityChange, verbosityLabel } from "./verbosity"
type PanelEntry = RunFooterMenuItem & {
category: string
@@ -45,6 +46,7 @@ type CommandEntry =
| (PanelEntry & { action: "variant.list" })
| (PanelEntry & { action: "settings" })
| (PanelEntry & { action: "slash"; name: string })
| (PanelEntry & { action: "clear" })
| (PanelEntry & { action: "exit" })
type ModelEntry = PanelEntry & {
@@ -78,7 +80,7 @@ type SubagentEntry = PanelEntry & {
}
type SettingEntry = PanelEntry & {
key: keyof MiniSettings
key: keyof MiniSettings | "verbosity"
}
const PANEL_PAD = 2
@@ -411,7 +413,9 @@ export function RunCommandMenuBody(props: {
onSettings: () => void
onCommand: (name: string) => void
onNew: () => void
onClear?: () => void
onExit: () => void
clearShortcut?: string
mono?: boolean
}) {
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
@@ -524,6 +528,13 @@ export function RunCommandMenuBody(props: {
...session,
...prompt,
...agent,
{
action: "clear",
category: "System",
display: "Clear screen",
footer: props.clearShortcut,
keywords: "clear screen cls redraw",
},
{
action: "settings",
category: "System",
@@ -585,6 +596,11 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "clear") {
props.onClear?.()
return
}
if (item.action === "exit") {
props.onExit()
return
@@ -711,8 +727,16 @@ export function RunSettingsBody(props: {
mono?: boolean
animations?: boolean
}) {
const [saving, setSaving] = createSignal<keyof MiniSettings>()
const [saving, setSaving] = createSignal<SettingEntry["key"]>()
const entries = createMemo<SettingEntry[]>(() => [
{
category: "Transcript",
display: "Verbosity",
footer: saving() === "verbosity" ? "saving" : verbosityLabel(matchMiniVerbosity(props.settings())),
footerTone: saving() === "verbosity" ? "running" : "selection",
keywords: `verbosity quiet default everything custom noise ${verbosityLabel(matchMiniVerbosity(props.settings()))}`,
key: "verbosity",
},
{
category: "Transcript",
display: "Thinking",
@@ -721,6 +745,14 @@ export function RunSettingsBody(props: {
keywords: `thinking reasoning ${props.settings().thinking}`,
key: "thinking",
},
{
category: "Transcript",
display: "Tools",
footer: saving() === "tools" ? "saving" : props.settings().tools,
footerTone: saving() === "tools" ? "running" : "selection",
keywords: `tools files skills activity work steps intermediate ${props.settings().tools}`,
key: "tools",
},
{
category: "Transcript",
display: "Shell",
@@ -785,18 +817,21 @@ export function RunSettingsBody(props: {
const change = (item: SettingEntry, direction = 1) => {
if (saving()) return
const spinners = Config.MiniWorkSpinner.literals
const next: MiniSettingChange =
item.key === "work_spinner"
? {
key: "work_spinner",
value:
spinners[
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
]!,
}
: item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
const next: MiniSettingChange | undefined =
item.key === "verbosity"
? verbosityChange(props.settings(), direction < 0 ? -1 : 1)
: item.key === "work_spinner"
? {
key: "work_spinner",
value:
spinners[
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
]!,
}
: item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
if (!next) return
setSaving(item.key)
void Promise.resolve(props.onChange(next))
.catch(() => {})
+5
View File
@@ -603,6 +603,7 @@ export class RunFooter implements FooterApi {
}
this.finishStartup()
if (this.miniSettings().tools === "hide" && toolTranscript(commit)) return
const last = this.queue.at(-1)
const merged = last ? coalesceProgressCommit(last, commit) : undefined
if (merged) this.queue[this.queue.length - 1] = merged
@@ -1168,6 +1169,10 @@ export class RunFooter implements FooterApi {
}
}
function toolTranscript(commit: StreamCommit) {
return commit.kind === "tool" || commit.partID?.startsWith("skill:")
}
/** @internal Exported for queue identity regression tests. */
export function coalesceProgressCommit(previous: StreamCommit, current: StreamCommit): StreamCommit | undefined {
if (
+25
View File
@@ -546,6 +546,26 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
const clearScreen = () => {
if (renderer.isDestroyed) return
if (renderer.screenMode !== "split-footer") return
if (renderer.externalOutputMode !== "capture-stdout") return
if (renderer.currentControlState === "explicit_suspended") return
// Home, erase the visible display, keep terminal scrollback, then repaint the footer.
renderer.resetSplitFooterForReplay({ clearSavedLines: false })
}
Keymap.createLayer(() => ({
commands: [
{
id: "app.clear",
title: "Clear screen",
group: "System",
run: clearScreen,
},
],
}))
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
@@ -831,7 +851,12 @@ export function RunFooterView(props: RunFooterViewProps) {
composer.submitText("/new")
closePanel()
}}
onClear={() => {
closePanel()
clearScreen()
}}
onExit={props.onExit}
clearShortcut={shortcut("app.clear")}
mono={props.mono}
/>
</Match>
+2 -1
View File
@@ -86,7 +86,8 @@ export async function resolveRunTuiConfig(
export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }): MiniSettings {
return {
thinking: config?.mini?.thinking ?? "hide",
thinking: config?.mini?.thinking ?? "show",
tools: config?.mini?.tools ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
+6
View File
@@ -22,6 +22,7 @@ import {
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import { verbosityPreset } from "./verbosity"
import type {
LocalReplayRow,
MiniHost,
@@ -255,6 +256,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
? async (change) => {
const info = await config.update((draft) => {
if (!draft.mini || typeof draft.mini !== "object") draft.mini = {}
if (change.key === "verbosity") {
Object.assign(draft.mini, verbosityPreset(change.value))
return
}
draft.mini[change.key] = change.value
})
configState.current = resolveMiniSettings(info)
@@ -793,6 +798,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
location: state.location,
sessionID: state.sessionID,
thinking: thinking(),
tools: configState.current.tools === "show",
replay: input.replay,
replayLimit: input.replayLimit,
footer,
+67 -5
View File
@@ -47,6 +47,7 @@ type StreamInput = {
location?: LocationRef
sessionID: string
thinking: boolean
tools?: boolean
replay?: boolean
replayLimit?: number
footer: FooterApi
@@ -141,6 +142,8 @@ type State = {
tools: Map<string, ToolState>
toolSources: Map<string, SessionMessageAssistantTool>
finishedTools: Set<string>
toolMessages: Set<string>
quietText: Map<string, Array<{ partID: string; text: string }>>
skillMessages: Set<string>
shellCommands: Map<string, string>
shellStarted: Set<string>
@@ -488,6 +491,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
tools: new Map(),
toolSources: new Map(),
finishedTools: new Set(),
toolMessages: new Set(),
quietText: new Map(),
skillMessages: new Set(),
shellCommands: new Map(),
shellStarted: new Set(),
@@ -592,7 +597,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const images = freshImages(userImageCommits(messageID, files), render)
if (!render) return
write([
...(!visible ? skillCommits(messageID, skills) : []),
...(!visible && showTools() ? skillCommits(messageID, skills) : []),
...(!visible && text.trim()
? [{ kind: "user", source: "system", text, phase: "start", messageID } as const]
: []),
@@ -662,7 +667,48 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
}
const showTools = () => input.tools !== false
const rememberToolMessage = (messageID: string) => {
state.toolMessages.add(messageID)
state.quietText.delete(messageID)
}
const bufferQuietText = (messageID: string, partID: string, text: string, replace: boolean) => {
if (!text || state.toolMessages.has(messageID)) return
const parts = state.quietText.get(messageID) ?? []
const current = parts.find((part) => part.partID === partID)
if (current) {
current.text = replace ? text : current.text + text
return
}
parts.push({ partID, text })
state.quietText.set(messageID, parts)
}
const flushQuietText = (messageID: string) => {
const parts = state.quietText.get(messageID)
state.quietText.delete(messageID)
if (!parts || state.toolMessages.has(messageID)) return
write(
parts
.filter((part) => part.text)
.map((part) => ({
kind: "assistant" as const,
source: "assistant" as const,
text: part.text,
phase: "progress" as const,
messageID,
partID: part.partID,
})),
)
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool, render = true) => {
if (!showTools()) {
rememberToolMessage(messageID)
render = false
}
const part = normalizeTool(item)
const key = permissionSourceKey(messageID, part.id)
if (state.finishedTools.has(key)) {
@@ -721,7 +767,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type === "skill") {
if (state.wait?.messageID === message.id) promoteWait(state.wait, false)
if (!render || state.skillMessages.has(message.id)) {
if (!render || !showTools() || state.skillMessages.has(message.id)) {
state.skillMessages.add(message.id)
return
}
@@ -784,13 +830,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
const hasTools = message.content.some((item) => item.type === "tool")
if (hasTools) rememberToolMessage(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const fragment = fragmentRef(message.id, "text", textOrdinal++)
const update = state.fragments.project(fragment, item.text, render)
if (render && item.text.length > update.previous.length)
const visible = render && (showTools() || !hasTools)
const update = state.fragments.project(fragment, item.text, visible)
if (visible && item.text.length > update.previous.length)
write([
{
kind: "assistant",
@@ -1024,7 +1073,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.wait?.messageID === messageID) promoteWait(state.wait, true)
if (state.skillMessages.has(messageID)) return
state.skillMessages.add(messageID)
write([skillCommit(messageID, event.data.name)])
if (showTools()) write([skillCommit(messageID, event.data.name)])
return
}
if (event.type === "session.compaction.started") {
@@ -1113,6 +1162,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.text.delta") {
const fragment = fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal)
if (!state.fragments.delta(fragment, event.data.delta)) return
if (!showTools()) {
bufferQuietText(event.data.assistantMessageID, fragment.partID, event.data.delta, false)
return
}
write([
{
kind: "assistant",
@@ -1130,6 +1183,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
event.data.text,
)
if (!showTools()) {
bufferQuietText(event.data.assistantMessageID, update.partID, event.data.text, true)
return
}
if (event.data.text.length > update.previous.length)
write([
{
@@ -1303,6 +1360,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
state.stepModel = undefined
if (!showTools()) flushQuietText(event.data.assistantMessageID)
write([], {
usage:
total > 0 || event.data.cost
@@ -1317,6 +1375,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (event.type === "session.step.failed") {
state.stepModel = undefined
if (!showTools()) flushQuietText(event.data.assistantMessageID)
const rendered = state.errors.has(event.data.assistantMessageID)
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
@@ -1344,6 +1403,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.type === "session.execution.interrupted"
) {
state.executionEpoch++
if (!showTools()) for (const messageID of [...state.quietText.keys()]) flushQuietText(messageID)
paintIdle("")
const current = state.wait
if (!current) return
@@ -1605,6 +1665,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.tools.clear()
state.toolSources.clear()
state.finishedTools.clear()
state.toolMessages.clear()
state.quietText.clear()
state.skillMessages.clear()
state.shellCommands.clear()
state.shellStarted.clear()
+8 -3
View File
@@ -402,6 +402,7 @@ export type RunTuiConfig = Pick<
export type MiniSettings = {
thinking: "show" | "hide"
tools: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
footer: "show" | "hide"
@@ -410,9 +411,13 @@ export type MiniSettings = {
mono: boolean
}
export type MiniSettingChange = {
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
}[keyof MiniSettings]
export type MiniVerbosity = "quiet" | "default" | "everything"
export type MiniSettingChange =
| {
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
}[keyof MiniSettings]
| { key: "verbosity"; value: MiniVerbosity }
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
+84
View File
@@ -0,0 +1,84 @@
import type { MiniSettingChange, MiniSettings, MiniVerbosity } from "./types"
const levels: readonly MiniVerbosity[] = ["quiet", "default", "everything"]
const knobs = ["thinking", "tools", "shell_output", "turn_summary", "footer"] as const
type VerbosityKnobs = Pick<MiniSettings, (typeof knobs)[number]>
const presets = {
quiet: {
thinking: "hide",
tools: "hide",
shell_output: "hide",
turn_summary: "hide",
footer: "hide",
},
default: {
thinking: "show",
tools: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
},
everything: {
thinking: "show",
tools: "show",
shell_output: "show",
turn_summary: "show",
footer: "show",
},
} as const satisfies Record<MiniVerbosity, VerbosityKnobs>
const labels = {
quiet: "Quiet",
default: "Default",
everything: "Everything",
custom: "Custom",
} as const
export function verbosityPreset(level: MiniVerbosity): VerbosityKnobs {
return { ...presets[level] }
}
export function verbosityLabel(level: MiniVerbosity | "custom") {
return labels[level]
}
export function matchMiniVerbosity(settings: MiniSettings): MiniVerbosity | "custom" {
for (const level of levels) {
if (knobs.every((key) => settings[key] === presets[level][key])) return level
}
return "custom"
}
export function cycleMiniVerbosity(settings: MiniSettings, direction: 1 | -1): MiniVerbosity {
const current = matchMiniVerbosity(settings)
const index = current === "custom" ? nearestMiniVerbosity(settings) : levels.indexOf(current)
const next = index + direction
if (next < 0) return levels[0]!
if (next >= levels.length) return levels[levels.length - 1]!
return levels[next]!
}
export function verbosityChange(settings: MiniSettings, direction: 1 | -1): MiniSettingChange | undefined {
const value = cycleMiniVerbosity(settings, direction)
if (matchMiniVerbosity(settings) === value) return
return { key: "verbosity", value }
}
export function applyMiniSettingChange(settings: MiniSettings, change: MiniSettingChange): MiniSettings {
if (change.key === "verbosity") return { ...settings, ...verbosityPreset(change.value) }
return { ...settings, [change.key]: change.value }
}
function nearestMiniVerbosity(settings: MiniSettings) {
return levels.reduce((best, level, index) => {
if (verbosityDistance(settings, level) < verbosityDistance(settings, levels[best]!)) return index
return best
}, 0)
}
function verbosityDistance(settings: MiniSettings, level: MiniVerbosity) {
return knobs.filter((key) => settings[key] !== presets[level][key]).length
}
+4 -2
View File
@@ -1153,7 +1153,7 @@ export function Session(props: {
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking)
? formatSessionTranscript(sessionData, messages(), options.thinking, options.tools)
: JSON.stringify(
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
null,
@@ -3839,7 +3839,7 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined
}
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean, tools = true) {
const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell")
@@ -3848,6 +3848,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
const content = message.content.flatMap((item) => {
if (item.type === "text") return [item.text]
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
if (!tools) return []
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
const output =
item.state.status === "error"
@@ -3859,6 +3860,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
.join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]
})
if (content.length === 0) return []
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
+1
View File
@@ -305,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
+45 -2
View File
@@ -13,12 +13,13 @@ export type DialogExportOptionsProps = {
action: "copy" | "export"
format: ExportFormat
thinking: boolean
tools: boolean
sanitize: boolean
}) => void
onCancel?: () => void
}
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export"
type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
tools: true,
sanitize: false,
active: "markdown" as Active,
})
@@ -36,6 +38,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
action,
format: store.format,
thinking: store.thinking,
tools: store.tools,
sanitize: store.sanitize,
})
@@ -45,6 +48,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return
}
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "tools") setStore("tools", !store.tools)
if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
if (store.active === "copy" || store.active === "export") confirm(store.active)
}
@@ -59,7 +63,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
run: () => {
const order: Active[] =
store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"]
? ["markdown", "json", "thinking", "tools", "copy", "export"]
: ["markdown", "json", "sanitize", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
},
@@ -160,6 +164,44 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
Include thinking
</text>
</box>
<box
flexDirection="row"
gap={1}
backgroundColor={
store.active === "tools"
? theme.background.formfield.focused
: store.tools
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "tools")
setStore("tools", !store.tools)
}}
>
<text
fg={
store.active === "tools"
? theme.text.formfield.focused
: store.tools
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.tools ? "[x]" : "[ ]"}
</text>
<text
fg={
store.active === "tools"
? theme.text.formfield.focused
: store.tools
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Include tools
</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
@@ -234,6 +276,7 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
action: "copy" | "export"
format: ExportFormat
thinking: boolean
tools: boolean
sanitize: boolean
} | null>((resolve) => {
dialog.replace(
+3 -2
View File
@@ -1,5 +1,6 @@
export const SESSION_SIDEBAR_WIDTH = 42
export const SESSION_SIDEBAR_MIN_WIDTH = 24
export const SESSION_TABS_COMPACT_WIDTH = 5
export const SESSION_TABS_COMPACT_BREAKPOINT = 12
export const SESSION_SIDEBAR_MAX_WIDTH = 72
const SESSION_CONTENT_MIN_WIDTH = 44
@@ -9,7 +10,7 @@ export function sessionTabsFitVertically(total: number, width = SESSION_SIDEBAR_
export function clampSessionTabsWidth(width: number, total: number) {
return Math.max(
SESSION_SIDEBAR_MIN_WIDTH,
SESSION_TABS_COMPACT_WIDTH,
Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH),
)
}
+40
View File
@@ -430,6 +430,46 @@ test("session title generated while an untitled session is loading remains visib
}
})
test("vertical session tabs collapse to a compact rail with the terminal", async () => {
await using state = await tmpdir()
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 42 }))
const session = {
id: "ses_resize",
title: "Resize fixture",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
await using setup = await createAppFixture({
width: 100,
state: state.path,
config: {
animations: false,
tabs: { enabled: true, layout: "vertical", indicators: "status" },
session: { sidebar: "hide" },
},
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/ses_resize\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
setup.resize(54, 30)
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 10).trim() === "⌕")
setup.resize(48, 30)
await setup.waitForFrame((frame) => frame.split("\n")[0].includes(session.title))
expect(setup.captureCharFrame()).not.toContain("⌕")
setup.resize(100, 30)
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const response = Promise.withResolvers<Response>()
@@ -0,0 +1,125 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { moveSessionTab, type SessionTab } from "../../src/context/session-tabs-model"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
import { SESSION_TABS_COMPACT_WIDTH } from "../../src/ui/layout"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("compact rail renders and controls session tabs", async () => {
const [active, setActive] = createSignal("first")
const [items, setItems] = createSignal<SessionTab[]>([
{ sessionID: "first", title: "First session" },
{ sessionID: "second", title: "Second session" },
{ sessionID: "third", title: "Third session" },
])
const [status, setStatus] = createSignal(EMPTY_SESSION_TAB_STATUS)
const [indicators, setIndicators] = createSignal<"status" | "numbers">("status")
const [searches, setSearches] = createSignal(0)
const controller = {
tabs: items,
current: active,
add() {},
search: () => setSearches((value) => value + 1),
select: setActive,
close() {},
move(sessionID: string, index: number) {
setItems((items) => moveSessionTab(items, sessionID, index))
},
detail: () => "project-alpha",
status: (sessionID: string) => (sessionID === "second" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { indicators: "status" } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box width="100%" height="100%" flexDirection="row">
<SessionTabs
controller={controller}
orientation="vertical"
animations={false}
indicators={indicators()}
width={SESSION_TABS_COMPACT_WIDTH}
/>
<text>transcript</text>
</box>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes(" F ") && frame.includes(" S "))
expect(
app
.captureCharFrame()
.split("\n")
.slice(0, 11)
.map((line) => line.slice(0, 5)),
).toEqual([
"▄▄▄▄▄",
" ⌕ ",
"▄▄▄▄▄",
" F ",
"▀▀▀▀▀",
" S ",
" ",
" T ",
" ",
" + ",
" ",
])
expect(app.captureCharFrame().split("\n")[0].indexOf("transcript")).toBe(5)
expect(app.captureCharFrame()).not.toContain("First session")
expect(
app.captureSpans().lines[3].spans.find((span) => span.text.trim() === "F")!.attributes & TextAttributes.BOLD,
).toBe(TextAttributes.BOLD)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === SPINNER_FRAMES[0])
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === "?")
setIndicators("numbers")
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === "2")
expect([3, 5, 7].map((row) => app.captureCharFrame().split("\n")[row].slice(0, 5).trim())).toEqual([
"1",
"2",
"3",
])
setIndicators("status")
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.mockMouse.moveTo(2, 5)
await app.waitForFrame((frame) => frame.includes("Second session") && frame.includes("project-alpha"))
expect(active()).toBe("first")
await app.mockMouse.moveTo(10, 0)
await app.waitForFrame((frame) => !frame.includes("Second session"))
await app.mockMouse.click(2, 1)
expect(searches()).toBe(1)
await app.mockMouse.drag(2, 3, 2, 7)
expect(items().map((tab) => tab.sessionID)).toEqual(["second", "third", "first"])
setItems(Array.from({ length: 40 }, (_, index) => ({ sessionID: `tab-${index + 1}`, title: `Session ${index + 1}` })))
setActive("tab-40")
setIndicators("numbers")
await app.waitForFrame((frame) => frame.split("\n").some((line) => line.slice(0, 5).trim() === "40"))
} finally {
app.renderer.destroy()
}
})
+3 -2
View File
@@ -18,9 +18,10 @@ test("validates the three explicit diff source defaults", () => {
})
test("validates mini replay and work spinner settings", () => {
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
expect(decodeInfo({ mini: { tools: "hide", replay: false, replay_limit: 50 } })).toEqual({
mini: { tools: "hide", replay: false, replay_limit: 50 },
})
expect(() => decodeInfo({ mini: { tools: "quiet" } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({
@@ -379,6 +379,7 @@ test.each(["once", "always", "reject"] as const)(
miniSettings: {
current: {
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "hide",
footer: "show",
@@ -67,6 +67,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
tuiConfig={config}
miniSettings={() => ({
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "show",
footer: "show",
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { expect, spyOn, test } from "bun:test"
import { RGBA, TextAttributes } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../src/mini/footer"
@@ -144,6 +144,35 @@ test.each([false, true])(
},
)
test("ctrl+l clears the split-footer screen without wiping scrollback", async () => {
const app = await setup()
try {
const clear = spyOn(app.renderer, "resetSplitFooterForReplay").mockImplementation(() => {})
await app.settle()
app.mockInput.pressKey("l", { ctrl: true })
expect(clear).toHaveBeenCalledWith({ clearSavedLines: false })
} finally {
app.cleanup()
}
})
test("command palette can clear the screen", async () => {
const app = await setup()
try {
const clear = spyOn(app.renderer, "resetSplitFooterForReplay").mockImplementation(() => {})
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("clear screen")
await app.settle()
expect(app.selected()).toContain("Clear screen")
app.mockInput.pressEnter()
expect(clear).toHaveBeenCalledWith({ clearSavedLines: false })
} finally {
app.cleanup()
}
})
test.each([false, true])("production command menu keeps navigation visible across resizes (mono=%s)", async (mono) => {
const app = await setup(mono)
try {
+48 -1
View File
@@ -34,6 +34,52 @@ test("coalesces progress only within the same message and tool state", () => {
)
})
test.each(["show", "hide"] as const)("tools setting %s controls tool and skill transcript", async (tools) => {
const app = await setup({ tools })
try {
app.footer.append({ kind: "user", text: "do the thing", phase: "start", source: "system" })
app.footer.append({
kind: "tool",
text: "running read",
phase: "start",
source: "tool",
tool: "read",
messageID: "msg_1",
partID: "prt_1",
})
app.footer.append({
kind: "system",
text: `→ Skill "demo"`,
phase: "start",
source: "system",
messageID: "msg_skill",
partID: "skill:demo",
})
app.footer.append({
kind: "assistant",
text: "all done",
phase: "progress",
source: "assistant",
messageID: "msg_2",
partID: "prt_text",
})
await app.footer.idle()
const text = app.externalOutput.takeText()
expect(text).toContain("do the thing")
expect(text).toContain("all done")
if (tools === "hide") {
expect(text).not.toContain("Read")
expect(text).not.toContain("Skill")
return
}
expect(text).toContain("-> Read")
expect(text).toContain("Skill")
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
test("falls back only when no agent is selected", () => {
const agents: RunAgent[] = [
{ id: "task", name: "Task", mode: "subagent", hidden: false },
@@ -50,6 +96,7 @@ test("falls back only when no agent is selected", () => {
async function setup(
input: {
mono?: boolean
tools?: MiniSettings["tools"]
theme?: RunTuiConfig["theme"]
startup?: { version: string; detail: string }
cursorRow?: number
@@ -93,7 +140,7 @@ async function setup(
theme: mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK,
tuiConfig: createTuiResolvedConfig({ theme: input.theme }),
miniSettings: {
current: { ...resolveMiniSettings(), mono },
current: { ...resolveMiniSettings(), mono, ...(input.tools ? { tools: input.tools } : {}) },
update: input.update,
},
onPermissionReply: () => {},
+32 -3
View File
@@ -24,6 +24,7 @@ import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, RUN_THEME_FALLBACK_LIGHT, resolveRunTheme, type RunTheme } from "../../src/mini/theme"
import { BLOCK_SOFT_SLIDE, SEED_MONO, WORK_SPINNERS } from "../../src/ui/one-cell-motion"
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
import { applyMiniSettingChange } from "../../src/mini/verbosity"
import type {
FooterQueuedPrompt,
FooterState,
@@ -936,7 +937,7 @@ test.each([false, true])("settings change preferences and preview the work spinn
settings={settings}
onClose={() => {}}
onChange={(change) => {
setSettings((current) => ({ ...current, [change.key]: change.value }))
setSettings((current) => applyMiniSettingChange(current, change))
}}
mono={mono}
animations={animations()}
@@ -951,7 +952,9 @@ test.each([false, true])("settings change preferences and preview the work spinn
const frame = app.captureCharFrame()
expect(frame).toContain("Settings")
expect(frame).toMatch(/^ +Settings/m)
expect(frame).toContain("Verbosity")
expect(frame).toContain("Thinking")
expect(frame).toContain("Tools")
expect(frame).toContain("Shell")
expect(frame).toContain("Turn summary")
expect(frame).toContain("Footer details")
@@ -960,14 +963,37 @@ test.each([false, true])("settings change preferences and preview the work spinn
expect(frame).toContain("left/right change")
if (mono) expect(frame).not.toMatch(/[^\x00-\x7F]/)
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "everything" }))
app.mockInput.pressKey("ARROW_LEFT")
await app.renderOnce()
expect(settings()).toEqual(resolveMiniSettings())
app.mockInput.pressKey("ARROW_LEFT")
await app.renderOnce()
expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "quiet" }))
app.mockInput.pressKey("ARROW_LEFT")
await app.renderOnce()
expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "quiet" }))
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual(resolveMiniSettings())
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({
...resolveMiniSettings(),
thinking: "show",
thinking: "hide",
})
expect(app.captureCharFrame()).toContain("Custom")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
@@ -975,7 +1001,7 @@ test.each([false, true])("settings change preferences and preview the work spinn
expect(settings()).toEqual({
...resolveMiniSettings(),
thinking: "show",
thinking: "hide",
turn_summary: "hide",
})
@@ -1987,6 +2013,7 @@ test.each([8, 12])("production footer grows for wrapped instructions in %i rows"
miniSettings: {
current: {
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "show",
footer: "show",
@@ -2054,6 +2081,7 @@ test.each(["ctrl+i", "none"])("takeovers preserve configured shortcuts with hidd
state: { phase: "running", interrupt: 1 },
miniSettings: {
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "show",
footer: "hide",
@@ -2371,6 +2399,7 @@ test("direct footer hides routine activity and shows explicit notices", async ()
currentAgent: "Plan",
miniSettings: {
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "show",
footer: "hide",
+5 -1
View File
@@ -20,6 +20,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("app.clear")?.[0]?.key).toBe("ctrl+l")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
@@ -38,7 +39,8 @@ describe("run runtime boot", () => {
expect(result.leader.timeout).toBe(450)
expect(result.cursor).toEqual({ style: "underline", blinking: false })
expect(resolveMiniSettings(result)).toEqual({
thinking: "hide",
thinking: "show",
tools: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
@@ -50,6 +52,7 @@ describe("run runtime boot", () => {
resolveMiniSettings({
mini: {
thinking: "show",
tools: "hide",
shell_output: "show",
turn_summary: "hide",
footer: "hide",
@@ -60,6 +63,7 @@ describe("run runtime boot", () => {
}),
).toEqual({
thinking: "show",
tools: "hide",
shell_output: "show",
turn_summary: "hide",
footer: "hide",
@@ -635,6 +635,135 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("hides tool-side assistant narration when tools are disabled", async () => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({ streams: [events], messages: { ses_1: [] } }),
sessionID: "ses_1",
thinking: false,
tools: false,
footer: ui.api,
})
const tokens = { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
events.push({
id: "evt_work_text",
created: 1,
type: "session.text.delta",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_work",
ordinal: 0,
delta: "I'll check.",
},
})
events.push({
id: "evt_tool_start",
created: 2,
type: "session.tool.input.started",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", assistantMessageID: "msg_work", id: "call_read", name: "read" },
})
events.push({
id: "evt_tool_called",
created: 3,
type: "session.tool.called",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", assistantMessageID: "msg_work", id: "call_read", input: {}, executed: true },
})
events.push({
id: "evt_work_step",
created: 4,
type: "session.step.ended",
durable: durable("ses_1", 3),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_work",
finish: "tool-calls",
cost: 0,
tokens,
},
})
events.push({
id: "evt_final_text",
created: 5,
type: "session.text.delta",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_final",
ordinal: 0,
delta: "Done.",
},
})
events.push({
id: "evt_final_step",
created: 6,
type: "session.step.ended",
durable: durable("ses_1", 4),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_final",
finish: "stop",
cost: 0,
tokens,
},
})
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([
"Done.",
])
await transport.close()
})
test("hides tool-side assistant narration from hydrated history when tools are disabled", async () => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
streams: [events],
messages: {
ses_1: [
{
id: "msg_final",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "Done." }],
time: { created: 4, completed: 5 },
},
{
id: "msg_work",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "text", text: "I'll check." },
canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }),
],
time: { created: 2, completed: 3 },
},
{ id: "msg_user", type: "user", text: "what happened", files: [], agents: [], time: { created: 1 } },
],
},
}),
sessionID: "ses_1",
thinking: false,
tools: false,
replay: true,
footer: ui.api,
})
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(
ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
).toEqual(["what happened", "Done."])
await transport.close()
})
test("recursively hydrates blockers for direct and transitive descendants", async () => {
const events = feed()
events.push(connected())
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test"
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
import {
applyMiniSettingChange,
cycleMiniVerbosity,
matchMiniVerbosity,
verbosityChange,
verbosityLabel,
verbosityPreset,
} from "../../src/mini/verbosity"
test("default Mini settings match the default verbosity preset", () => {
expect(matchMiniVerbosity(resolveMiniSettings())).toBe("default")
})
test("verbosity presets leave splash, spinner, and mono alone", () => {
const current = resolveMiniSettings({ mini: { splash: "hide", work_spinner: "seed", mono: true } })
const quiet = applyMiniSettingChange(current, { key: "verbosity", value: "quiet" })
expect(quiet).toEqual({
...current,
...verbosityPreset("quiet"),
})
expect(quiet.splash).toBe("hide")
expect(quiet.footer).toBe("hide")
expect(applyMiniSettingChange(current, { key: "verbosity", value: "everything" }).mono).toBe(true)
expect(applyMiniSettingChange(current, { key: "thinking", value: "show" }).thinking).toBe("show")
})
test("individual knobs mark verbosity custom until a preset matches again", () => {
const louder = applyMiniSettingChange(resolveMiniSettings(), { key: "tools", value: "show" })
expect(matchMiniVerbosity(louder)).toBe("custom")
expect(verbosityLabel("custom")).toBe("Custom")
expect(matchMiniVerbosity(applyMiniSettingChange(louder, { key: "verbosity", value: "quiet" }))).toBe("quiet")
})
test("verbosity cycles like a clamped slider", () => {
const settings = resolveMiniSettings()
expect(cycleMiniVerbosity(settings, 1)).toBe("everything")
expect(cycleMiniVerbosity(settings, -1)).toBe("quiet")
expect(cycleMiniVerbosity({ ...settings, ...verbosityPreset("quiet") }, -1)).toBe("quiet")
expect(cycleMiniVerbosity({ ...settings, ...verbosityPreset("everything") }, 1)).toBe("everything")
expect(verbosityChange({ ...settings, ...verbosityPreset("quiet") }, -1)).toBeUndefined()
expect(verbosityChange(settings, 1)).toEqual({ key: "verbosity", value: "everything" })
expect(verbosityLabel("quiet")).toBe("Quiet")
expect(verbosityLabel("everything")).toBe("Everything")
})
test("custom verbosity moves toward the nearest preset", () => {
const custom = applyMiniSettingChange(resolveMiniSettings(), { key: "tools", value: "show" })
expect(matchMiniVerbosity(custom)).toBe("custom")
expect(cycleMiniVerbosity(custom, 1)).toBe("everything")
expect(cycleMiniVerbosity(custom, -1)).toBe("quiet")
})
+7 -2
View File
@@ -3,8 +3,8 @@ import {
clampSessionTabsWidth,
sessionTabsFitVertically,
SESSION_SIDEBAR_MAX_WIDTH,
SESSION_SIDEBAR_MIN_WIDTH,
SESSION_SIDEBAR_WIDTH,
SESSION_TABS_COMPACT_WIDTH,
} from "../../src/ui/layout"
test("vertical tabs match the session sidebar and preserve compact content width", () => {
@@ -19,8 +19,13 @@ test("vertical tabs account for a resized width", () => {
})
test("vertical tab width preserves minimum rail and content widths", () => {
expect(clampSessionTabsWidth(10, 120)).toBe(SESSION_SIDEBAR_MIN_WIDTH)
expect(clampSessionTabsWidth(0, 120)).toBe(SESSION_TABS_COMPACT_WIDTH)
expect(clampSessionTabsWidth(11, 120)).toBe(11)
expect(clampSessionTabsWidth(12, 120)).toBe(12)
expect(clampSessionTabsWidth(50, 120)).toBe(50)
expect(clampSessionTabsWidth(100, 120)).toBe(SESSION_SIDEBAR_MAX_WIDTH)
expect(clampSessionTabsWidth(100, 100)).toBe(56)
expect(clampSessionTabsWidth(42, 54)).toBe(10)
expect(sessionTabsFitVertically(49, SESSION_TABS_COMPACT_WIDTH)).toBe(true)
expect(sessionTabsFitVertically(48, SESSION_TABS_COMPACT_WIDTH)).toBe(false)
})
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -99,6 +99,22 @@ Unknown command IDs are rejected.
| `service.restart` | `none` | Restart service |
| `permission.mode` | `none` | Toggle auto-approve permissions |
## Mini
The `app.clear` command works only in [`opencode2 mini`](/cli). Press `ctrl+l` to clear the visible screen and draw the prompt again. The terminal keeps the scrollback.
```json title="cli.json"
{
"keybinds": {
"app.clear": "ctrl+l"
}
}
```
| ID | Default | Description |
| ----------- | -------- | ---------------- |
| `app.clear` | `ctrl+l` | Clear the screen |
## Diff Viewer
Press `d` (`diff.switch_source`) to choose **All**, **Committed**, or **Uncommitted**, or select **Base** to change the comparison branch. Choices are remembered until the TUI exits. Set the initial scope with [`diffs.source` in `cli.json`](/cli/config#diffs).