mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb2042acd | ||
|
|
f9bc2233dd | ||
|
|
7487999e06 | ||
|
|
2eea36e731 | ||
|
|
4fef8edbe8 | ||
|
|
50c552f763 | ||
|
|
a3d5923aca | ||
|
|
ea2c0184ce | ||
|
|
54504ab3a5 | ||
|
|
cc5086d127 | ||
|
|
b20482461c | ||
|
|
5b5368fe98 |
@@ -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: {} })
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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">>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
@@ -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 [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user