Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 3a22a0251e fix(app): use custom composer scrollbar 2026-07-31 10:11:43 +00:00
61 changed files with 351 additions and 860 deletions
-2
View File
@@ -67,7 +67,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -521,7 +520,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
+1 -5
View File
@@ -107,7 +107,6 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -416,7 +415,6 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request),
@@ -429,9 +427,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
max_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty,
-1
View File
@@ -6,7 +6,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+48
View File
@@ -28,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
-5
View File
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {}
export namespace ModelCompatibility {
+18 -21
View File
@@ -171,27 +171,24 @@ describe("request option precedence", () => {
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
-1
View File
@@ -59,7 +59,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -12,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import { sessionTitle } from "@/utils/session-title"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
})
const projectName = createMemo(() => {
@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import { sessionTitle } from "@/utils/session-title"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => displayLabel(props.session)
const title = () => sessionTitle(props.session.title, props.session.parentID)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={displayLabel(props.session)}
value={sessionTitle(props.session.title, props.session.parentID)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,10 +296,11 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
return sessionTitle(titleValue(), session.parentID)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
@@ -316,7 +317,7 @@ export function MessageTimeline(props: {
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
@@ -920,7 +921,7 @@ export function MessageTimeline(props: {
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test"
import { sessionTitle } from "./session-title"
describe("sessionTitle", () => {
test("uses a display fallback without persisting it", () => {
expect(sessionTitle(undefined)).toBe("New session")
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
expect(sessionTitle("Generated title")).toBe("Generated title")
})
})
+7
View File
@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string, parentID?: string) {
if (!title) return parentID ? "Child session" : "New session"
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: withTimestampedFallback(input),
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
agent: input.agent,
model: input.model,
version: "",
+2 -3
View File
@@ -58,9 +58,8 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative),
})),
]
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
await Promise.all(unique.map((asset) => stat(asset.source)))
return unique
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
+3 -2
View File
@@ -8,7 +8,6 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -214,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
title:
session.title ??
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
-17
View File
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
+1 -1
View File
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
limits: { context: info.limit.context, output: info.limit.output },
providerOptions,
},
body: {
+2 -2
View File
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -204,7 +204,7 @@ export const fromCatalogModel = (
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
+6 -2
View File
@@ -220,8 +220,12 @@ export const OpenAIPlugin = define({
return
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
if (draft.id.includes("gpt-5.5")) {
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
}
if (draft.id.includes("gpt-5.6")) {
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
}
})
}
})
+10 -12
View File
@@ -152,11 +152,14 @@ const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
@@ -347,16 +350,11 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const limits = input.model.route.defaults.limits
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
context - Math.max(output, config.buffer),
)
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return used >= context - (output || config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
+1 -5
View File
@@ -6,7 +6,6 @@ import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@@ -40,10 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
})
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
+2 -4
View File
@@ -17,7 +17,6 @@ interface ModelOptions {
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
readonly limit?: Info["limit"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
@@ -37,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: options.limit ?? { context: 100, output: 20 },
limit: { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -45,7 +44,6 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
@@ -57,7 +55,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, input: 80, output: 20 },
limits: { context: 100, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -74,9 +74,6 @@ describe("OpenAIPlugin", () => {
]
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
@@ -140,7 +137,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -148,15 +145,10 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
+1 -49
View File
@@ -19,11 +19,9 @@ import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -132,52 +130,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits }),
}),
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
-1
View File
@@ -16,7 +16,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -159,7 +158,11 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(() => withTimestampedFallback(info()))
const title = createMemo(
() =>
info().title ??
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
)
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
-15
View File
@@ -26,27 +26,12 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
@@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -140,44 +141,46 @@ export function PromptInputV2(props: PromptInputV2Props) {
</Show>
<div class="relative min-h-[60px]">
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<ScrollView class="min-h-[60px] max-h-[180px]">
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
</ScrollView>
<Show when={!props.controller.value()}>
<div
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
+3 -6
View File
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
@@ -537,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (route.data.type === "session") {
const title = session?.title
if (!title || isFallbackTitle(title)) {
if (!title || isDefaultTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}
@@ -646,10 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
dialog.clear()
},
+1 -1
View File
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "global",
default: "cwd",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
@@ -159,10 +159,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(
1,
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
)
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
+6 -7
View File
@@ -2,8 +2,8 @@ import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -16,7 +16,7 @@ import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
@@ -80,11 +80,10 @@ export function DialogOpen() {
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
title: sessionTitle(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? () => <Spinner />
: tabs.has(session.id)
@@ -105,8 +104,8 @@ export function DialogOpen() {
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
return {
title,
footer: truncateFilePath(footer, width),
@@ -18,8 +18,7 @@ import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
export function DialogSessionList() {
const dialog = useDialog()
@@ -31,15 +30,12 @@ export function DialogSessionList() {
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const config = useConfig().data
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [prefs, updatePrefs] = useStorage().store("session-list", {
initial: { allProjects: config.tabs?.scope !== "cwd" },
})
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
const allProjects = () => prefs.allProjects
const [searchResults, { mutate: setSearchResults }] = createResource(
@@ -82,9 +78,7 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter(
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
)
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter().trim()
@@ -146,9 +140,7 @@ export function DialogSessionList() {
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
: withTimestampedFallback(session),
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
value: session.id,
category,
footer,
@@ -244,9 +236,7 @@ export function DialogSessionList() {
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
: result,
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
+5 -23
View File
@@ -45,7 +45,6 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
@@ -62,7 +61,7 @@ type LocationData = {
websearch?: WebSearchProvider[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellWithLocation>
shell?: Record<string, ShellInfo>
skill?: SkillInfo[]
}
@@ -375,11 +374,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
@@ -855,10 +851,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "shell.created":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
},
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
}))
break
case "shell.exited":
@@ -1113,18 +1106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
})
})
},
+21 -33
View File
@@ -3,7 +3,7 @@ import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
@@ -66,33 +66,18 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
if (config.tabs?.scope === "global") return store.global
return store.cwd[paths.cwd] ?? fallback
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global"
const scope = config.tabs?.scope ?? "cwd"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
)
}
const root = (sessionID: string) => data.session.root(sessionID)
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
@@ -130,29 +115,32 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
const session = data.session.get(sessionID)
const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined)
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
delete draft.unread[sessionID]
})
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
if (isDeepEqual(next, state())) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
const session = data.session.get(sessionID)
return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title })
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
draft.tabs = next
draft.unread = unread
})
})
+1 -18
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,20 +13,12 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -45,7 +37,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -82,14 +73,6 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
+3 -3
View File
@@ -10,7 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "../util/session"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
@@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) {
if (title && !isDefaultTitle(title)) {
return {
title,
showSession: true,
@@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const setTitle = (title?: string) => {
if (input.host.platform !== "linux") return
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
}
setTitle(input.sessionTitle)
-1
View File
@@ -240,7 +240,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
memory: (key, options) => storage.memory(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
@@ -2,6 +2,7 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
@@ -9,14 +10,13 @@ import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
)
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
@@ -83,9 +83,10 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
const ref = location.current
void client.api.shell.remove({
id: entry.id,
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
})
},
},
@@ -8,7 +8,7 @@ import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../../util/session"
interface SubagentEntry {
sessionID: string
@@ -39,7 +39,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const title = withTimestampedFallback(sibling)
const title = sessionTitle(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
@@ -58,7 +58,7 @@ export function SubagentsTab(props: { sessionID: string }) {
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const title = withTimestampedFallback(child)
const title = sessionTitle(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
+30 -3
View File
@@ -51,6 +51,7 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogConfirm } from "../../ui/dialog-confirm"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
@@ -92,7 +93,7 @@ import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../util/session"
addDefaultParsers(parsers.parsers)
@@ -143,8 +144,8 @@ export function Session() {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
createEffect(() => currentLocation.set(location()))
@@ -522,6 +523,32 @@ export function Session() {
slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Delete session",
id: "session.delete",
group: "Session",
slash: { name: "delete" },
run: async () => {
const current = session()
if (!current) return
const confirmed = await DialogConfirm.show(
dialog,
"Delete Session",
`Delete "${current.title}"? This action cannot be undone.`,
)
if (confirmed !== true) return
const error = await client.api.session.remove({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
if (!error) return
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
},
},
{
title: "Jump to message",
id: "session.timeline",
@@ -3308,7 +3335,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
})
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`
return `# ${sessionTitle(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`
}
export function parseApplyPatchFiles(value: unknown) {
+2 -2
View File
@@ -3,7 +3,7 @@ import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../util/session"
import { getScrollAcceleration } from "../../util/scroll"
@@ -39,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
<b>{sessionTitle(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
-5
View File
@@ -83,11 +83,6 @@ export interface DialogSelectOption<T = any> {
onSelect?: (ctx: DialogContext) => void
}
export function dialogSelectContentWidth(dialogWidth: number) {
// Scroll padding, row padding, the gutter, title padding, and the separating gap.
return dialogWidth - 12
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
+9 -11
View File
@@ -8,17 +8,9 @@ import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
export type DialogSize = "medium" | "large" | "xlarge"
export function dialogWidth(size: DialogSize) {
if (size === "xlarge") return 116
if (size === "large") return 88
return 60
}
export function Dialog(
props: ParentProps<{
size?: DialogSize
size?: "medium" | "large" | "xlarge"
centered?: boolean
onClose: () => void
}>,
@@ -28,6 +20,12 @@ export function Dialog(
const renderer = useRenderer()
let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return (
<box
onMouseDown={() => {
@@ -59,7 +57,7 @@ export function Dialog(
dismiss = false
e.stopPropagation()
}}
width={dialogWidth(props.size ?? "medium")}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.background.default}
paddingTop={1}
@@ -76,7 +74,7 @@ function init() {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as DialogSize,
size: "medium" as "medium" | "large" | "xlarge",
centered: false,
})
+13 -1
View File
@@ -1,6 +1,18 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
export function isDefaultTitle(title?: string) {
return (
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
)
}
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
return (
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
-88
View File
@@ -138,94 +138,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after
}
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OC | Generated title") generatedTitle.resolve()
setTitle(title)
}
const sessionRequested = Promise.withResolvers<void>()
const renameSyncRequested = Promise.withResolvers<void>()
const releaseSession = Promise.withResolvers<void>()
let sessionRequests = 0
const session = {
id: "dummy",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const events = createEventStream()
const calls = createFetch(async (url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy") {
sessionRequests++
sessionRequested.resolve()
if (sessionRequests === 2) renameSyncRequested.resolve()
await releaseSession.promise
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Generated title" },
})
await Promise.race([
renameSyncRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("rename sync did not start")
}),
])
releaseSession.resolve()
await Promise.race([
generatedTitle.promise,
Bun.sleep(2_000).then(() => {
throw new Error("generated title was not shown")
}),
])
await Bun.sleep(20)
const generated = titles.lastIndexOf("OC | Generated title")
expect(generated).toBeGreaterThan(-1)
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
+8 -44
View File
@@ -9,11 +9,7 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -1786,19 +1782,12 @@ test("refreshes references after updates", async () => {
test("keeps shell state scoped to location", async () => {
const events = createEventStream()
const other = "/tmp/opencode/other"
const workspace = "ws_other"
let removed: URL | undefined
const calls = createFetch((url, request) => {
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
removed = url
return new Response(null, { status: 204 })
}
const calls = createFetch((url) => {
if (url.pathname !== "/api/shell") return
const requestDirectory = url.searchParams.get("location[directory]")
return json({
location: {
directory: requestDirectory ?? directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "proj_test", directory: requestDirectory ?? directory },
},
data: [
@@ -1809,7 +1798,7 @@ test("keeps shell state scoped to location", async () => {
cwd: requestDirectory ?? directory,
shell: "/bin/sh",
file: "/tmp/opencode-shell",
metadata: { sessionID: "ses_shared" },
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
time: { started: 1 },
},
],
@@ -1819,15 +1808,7 @@ test("keeps shell state scoped to location", async () => {
function Probe() {
data = useData()
return (
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</ThemeProvider>
</Keymap.Provider>
</RouteProvider>
)
return <box />
}
const app = await testRender(() => (
@@ -1841,31 +1822,19 @@ test("keeps shell state scoped to location", async () => {
</ClientProvider>
</TestTuiContexts>
))
app.renderer.start()
try {
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
await data.shell.sync({ directory: other, workspaceID: workspace })
await data.shell.sync({ directory: other })
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
["sh_default", directory],
["sh_other", other],
])
await app.waitForFrame((frame) => frame.includes("pnpm dev"))
app.mockInput.pressArrow("down")
app.mockInput.pressKey("d", { ctrl: true })
await wait(() => removed !== undefined)
expect(removed?.searchParams.get("location[directory]")).toBe(other)
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other, workspaceID: workspace },
location: { directory: other },
data: {
info: {
id: "sh_live_other",
@@ -1874,18 +1843,13 @@ test("keeps shell state scoped to location", async () => {
cwd: other,
shell: "/bin/sh",
file: "/tmp/opencode-shell-live",
metadata: { sessionID: "ses_shared" },
metadata: { sessionID: "ses_other" },
time: { started: 2 },
},
},
})
await wait(() =>
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
)
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
other,
)
} finally {
app.renderer.destroy()
}
@@ -1,106 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { onMount } from "solid-js"
import { DialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTabsProvider } from "../../../src/context/session-tabs"
import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/session") return
return json({
data: [
{
id: "ses_remote",
projectID: "proj_remote",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Remote session",
location: remote,
},
],
cursor: {},
})
}, events)
let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
function Probe() {
const dialog = useDialog()
route = useRoute()
location = useLocation()
data = useData()
onMount(() => dialog.replace(() => <DialogOpen />))
return null
}
const app = await testRender(
() => (
<TestTuiContexts paths={{ state }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
try {
await app.waitForFrame((frame) => frame.includes("Remote session"))
expect(data.session.get("ses_remote")).toBeUndefined()
app.mockInput.pressEnter()
await app.waitFor(() => route.data.type === "session")
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(location.ref).toEqual(remote)
} finally {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
}
})
@@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import { dialogWidth } from "../../../src/ui/dialog"
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
import { truncateFilePath } from "../../../src/ui/file-path"
import { stringWidth } from "../../../src/util/string-width"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -150,28 +147,6 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
return { app, moved, replaceOptions, selected }
}
test("budgets option content for constrained and full-width large dialogs", () => {
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
})
test("renders the complete truncated footer within the option row", async () => {
await using tmp = await tmpdir()
const title = "Project"
const footer = truncateFilePath(
"/tmp/opencode/projects/a-very-long-project-directory/distinctive-tail.tsx",
dialogSelectContentWidth(dialogWidth("medium")) - stringWidth(title),
)
const select = await mountSelect(tmp.path, [{ title, footer, value: "project" }])
try {
await select.app.waitForFrame((frame) => frame.includes(footer))
expect(select.app.captureCharFrame()).toContain(footer)
} finally {
select.app.renderer.destroy()
}
})
test("renders actions with a current selection", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(
+15 -87
View File
@@ -2,56 +2,41 @@
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync, watch } from "fs"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { DataProvider } from "../../src/context/data"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
import { StorageProvider } from "../../src/context/storage"
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function wait(fn: () => boolean, timeout = 2_000) {
const start = Date.now()
while (!(await fn())) {
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
async function renderSessionTabs(initialSessionID: string) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: initialSessionID,
title: options?.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
}, events)
const calls = createFetch(undefined, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
data = useData()
return <box />
}
@@ -79,75 +64,14 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
return {
tabs,
route,
data,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
if (!options?.state) rmSync(state, { recursive: true, force: true })
rmSync(state, { recursive: true, force: true })
},
}
}
test("stores session tabs globally by default", async () => {
const setup = await renderSessionTabs("first")
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({
global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: {},
})
} finally {
setup.destroy()
}
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
titled = await renderSessionTabs("shared", { state, title: "Generated title" })
untitled = await renderSessionTabs("shared", { state })
const file = path.join(state, "test", "tui", "tabs.json")
await titled.data.session.sync("shared")
await wait(async () => {
if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
})
const observed = ["Generated title"]
const pending = new Set<Promise<void>>()
const watcher = watch(path.dirname(file), (_, name) => {
if (name !== path.basename(file)) return
const read = Bun.file(file)
.json()
.then((value) => {
const title = value.global.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title)
})
.catch(() => undefined)
.finally(() => pending.delete(read))
pending.add(read)
})
try {
await untitled.data.session.sync("shared")
await Bun.sleep(500)
} finally {
watcher.close()
await Promise.allSettled(pending)
}
expect(observed).toEqual(["Generated title"])
} finally {
titled?.destroy()
untitled?.destroy()
rmSync(state, { recursive: true, force: true })
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
@@ -182,7 +106,9 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
expect(setup.tabs.status("background").promptPulse).toBe(0)
setup.emit(admitted("background", "msg_1"))
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
await wait(
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
)
setup.emit(admitted("background", "msg_2"))
await wait(() => setup.tabs.status("background").promptPulse === 2)
@@ -218,7 +144,9 @@ test("tracks a temporary new session tab across close and creation", async () =>
await wait(() => setup.tabs.newTab())
setup.route.navigate({ type: "session", sessionID: "third" })
expect(setup.tabs.newTab()).toBe(true)
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
)
expect(setup.tabs.newTab()).toBe(false)
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
@@ -1,44 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage, type Storage } from "../../src/context/storage"
import { TestTuiContexts } from "../fixture/tui-environment"
test("memory storage is synchronous, keyed, and stable across lookups", async () => {
let storage!: Storage
function Probe() {
storage = useStorage()
return <box />
}
await testRender(() => (
<TestTuiContexts paths={{ state: mkdtempSync(path.join(tmpdir(), "opencode-storage-test-")) }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<Probe />
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const [state, update] = storage.memory("tick", { initial: { count: 0, at: undefined as Date | undefined } })
// Synchronous update, no JSON round-trip: a Date survives as-is.
const now = new Date()
update((draft) => {
draft.count += 1
draft.at = now
})
expect(state.count).toBe(1)
expect(state.at).toBe(now)
// Same key returns the same live store (what hot-reload survival relies
// on); a different key is isolated.
const [again] = storage.memory("tick", { initial: { count: 99, at: undefined as Date | undefined } })
expect(again).toBe(state)
expect(again.count).toBe(1)
const [other] = storage.memory("other", { initial: { count: 0 } })
expect(other.count).toBe(0)
})
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage } from "../../src/util/session"
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -13,6 +13,22 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
})
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle(undefined)).toBeTrue()
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("derives display-only titles for untitled sessions", () => {
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
"New session - 1970-01-01T00:00:00.000Z",
)
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
"Child session - 1970-01-01T00:00:00.000Z",
)
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
-1
View File
@@ -28,7 +28,6 @@
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -1,36 +0,0 @@
export * as SessionTitleFallback from "./session-title-fallback.js"
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
interface Info {
readonly title?: string
readonly parentID?: string
readonly time: {
readonly created: number
}
}
/** Supplies the timestamped title required by compatibility surfaces. */
export function withTimestampedFallback(info: Info) {
return info.title ?? fallback(info)
}
/** Supplies a compact human label and collapses historical timestamped fallbacks. */
export function displayLabel(info: Pick<Info, "title" | "parentID">) {
if (!info.title) return info.parentID ? "Child session" : "New session"
return info.title.match(pattern)?.[1] ?? info.title
}
/** Recognizes missing and historical root or child fallback titles. */
export function isFallbackTitle(title?: string) {
return title === undefined || pattern.test(title)
}
/** Recognizes a missing title or the exact root fallback for this session. */
export function isExactRootFallback(info: Pick<Info, "title" | "time">) {
return info.title === undefined || info.title === fallback({ time: info.time })
}
function fallback(info: Pick<Info, "parentID" | "time">) {
return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}`
}
@@ -1,49 +0,0 @@
import { describe, expect, test } from "bun:test"
import { SessionTitleFallback } from "../src/session-title-fallback.js"
const root = { title: undefined, time: { created: 0 } }
const child = { ...root, parentID: "ses_parent" }
describe("SessionTitleFallback", () => {
test("supplies timestamped compatibility titles", () => {
expect(SessionTitleFallback.withTimestampedFallback(root)).toBe("New session - 1970-01-01T00:00:00.000Z")
expect(SessionTitleFallback.withTimestampedFallback(child)).toBe("Child session - 1970-01-01T00:00:00.000Z")
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "Generated title" })).toBe("Generated title")
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "" })).toBe("")
})
test("supplies compact display labels", () => {
expect(SessionTitleFallback.displayLabel(root)).toBe("New session")
expect(SessionTitleFallback.displayLabel(child)).toBe("Child session")
expect(SessionTitleFallback.displayLabel({ title: "", parentID: "ses_parent" })).toBe("Child session")
expect(SessionTitleFallback.displayLabel({ title: "New session - 2026-07-30T18:45:03.662Z" })).toBe("New session")
expect(SessionTitleFallback.displayLabel({ title: "Child session - 2026-07-30T18:45:03.662Z" })).toBe(
"Child session",
)
expect(SessionTitleFallback.displayLabel({ title: "Generated title" })).toBe("Generated title")
})
test("recognizes historical fallback titles", () => {
expect(SessionTitleFallback.isFallbackTitle(undefined)).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("New session - 2026-07-30T18:45:03.662Z")).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("Child session - 2026-07-30T18:45:03.662Z")).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("")).toBeFalse()
expect(SessionTitleFallback.isFallbackTitle("New session - custom")).toBeFalse()
})
test("recognizes only the fallback associated with a session", () => {
expect(SessionTitleFallback.isExactRootFallback(root)).toBeTrue()
expect(
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 1970-01-01T00:00:00.000Z" }),
).toBeTrue()
expect(
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 2099-01-01T00:00:00.000Z" }),
).toBeFalse()
expect(
SessionTitleFallback.isExactRootFallback({
...child,
title: "Child session - 1970-01-01T00:00:00.000Z",
}),
).toBeFalse()
})
})
@@ -95,7 +95,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
| `buffer` | `20000` | Token reserve used by the automatic threshold. The requested model output allowance wins when it is larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
preserves more recent detail but leaves less room for future work. Larger