Compare commits

..

10 Commits

Author SHA1 Message Date
Ryan Vogel 094dac1541 fix(core): roll session activity into ancestors 2026-08-01 12:02:07 -04:00
Ryan Vogel 729531550c fix(tui): preserve family session recency 2026-08-01 11:48:29 -04:00
Ryan Vogel dbd934689f fix(tui): address inbox review findings 2026-08-01 15:20:41 +00:00
Ryan Vogel 4f936c2c95 Merge remote-tracking branch 'origin/v2' into tui-inbox-tabs 2026-08-01 11:06:51 -04:00
Ryan Vogel 46662b7eec fix(tui): strip markdown from inbox previews 2026-08-01 11:04:55 -04:00
Ryan Vogel 0bd75af069 fix(tui): add inbox pane gutter 2026-08-01 10:58:26 -04:00
Ryan Vogel 7c78d5ca44 Revert "feat(core): add session rename tool"
This reverts commit 1b78dd7f9c.
2026-08-01 10:55:35 -04:00
Ryan Vogel b20d62de6a fix(tui): remove inbox divider 2026-08-01 10:52:52 -04:00
Ryan Vogel 1b78dd7f9c feat(core): add session rename tool 2026-08-01 10:44:18 -04:00
Ryan Vogel a01d24e908 feat(tui): add inbox tab layout 2026-08-01 10:44:18 -04:00
45 changed files with 1179 additions and 542 deletions
+9 -8
View File
@@ -16,6 +16,7 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -32,6 +33,7 @@ const patterns = [
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
@@ -42,15 +44,11 @@ const patterns = [
/token limit exceeded/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
@@ -102,8 +100,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common)
@@ -146,7 +142,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
+4 -7
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -96,10 +96,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedModelInput,
) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
@@ -153,7 +150,7 @@ export interface Interface {
}
export interface StreamOptions {
readonly http?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
@@ -305,7 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
+5 -38
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Stream } from "effect"
import { Cause, Context, Effect, Layer } from "effect"
import {
FetchHttpClient,
Headers,
@@ -20,13 +20,9 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface {
readonly execute: (
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
}
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
const BODY_LIMIT = 16_384
@@ -286,41 +282,12 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
let sent = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const response = yield* HttpClientRequest.toWeb(request).pipe(
Effect.flatMap((web) =>
middleware(web, (input) =>
Effect.gen(function* () {
sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
const response = yield* http
.execute(sent)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
const body = yield* Stream.toReadableStreamEffect(response.stream)
const web = new Response(body, { status: response.status, headers: response.headers })
origins.set(web, sent)
return web
}),
),
),
Effect.mapError(toHttpError(redactedNames)),
)
const origin = origins.get(response) ?? sent
return yield* statusError(origin, redactedNames)(HttpClientResponse.fromWeb(origin, response))
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
})
return Service.of({
execute: executeOnce,
+1 -1
View File
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+9 -10
View File
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,7 +19,6 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -75,21 +74,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request,
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing,
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
+11 -3
View File
@@ -1,7 +1,7 @@
import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint"
import { Auth } from "../auth"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
@@ -10,6 +10,15 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -27,9 +36,8 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+7 -93
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
@@ -146,19 +146,12 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
const headers = new Headers(request.headers)
headers.set("x-plugin", "transformed")
headers.set("content-type", "application/custom+json")
return yield* handler(
new Request("https://proxy.test/v1/chat/completions", {
method: "PUT",
headers,
body: JSON.stringify({ transformed: true }),
}),
)
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
@@ -167,9 +160,7 @@ describe("request option precedence", () => {
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.method).toBe("PUT")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
@@ -180,83 +171,6 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () {
const response = yield* 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: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
const body = yield* Effect.promise(() => response.text())
return new Response(body.replace("network", "hooked"), {
status: response.status,
headers: response.headers,
})
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked")
}),
)
it.effect("can inspect an error response and retry the native request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const retry = request.clone()
const response = yield* handler(request)
expect(response.status).toBe(401)
const headers = new Headers(retry.headers)
headers.set("authorization", "Bearer refreshed")
return yield* handler(new Request(retry, { headers }))
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
if (input.request.headers.authorization !== "Bearer refreshed")
return input.respond("unauthorized", { status: 401 })
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("retried")
expect(yield* Ref.get(attempts)).toBe(2)
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
+3 -6
View File
@@ -85,17 +85,14 @@ describe("RequestExecutor", () => {
),
)
it.effect("classifies generic HTTP 413 payload errors", () =>
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
+4 -22
View File
@@ -6,6 +6,7 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -18,24 +19,6 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
@@ -76,10 +59,9 @@ describe("provider error classification", () => {
})
test("classifies transient client statuses as provider internal", () => {
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
"ProviderInternal",
])
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
+5 -5
View File
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly content?:
| readonly [
(
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly inputID?: SessionMessage.ID | undefined
}
}
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = { type: string; message: string }
export type SessionMessageCompactionRunning = {
type: "compaction"
+3 -32
View File
@@ -194,9 +194,7 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -265,35 +263,8 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) => {
if (name !== "http")
return register(
host.session.hook(name, (event) =>
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
),
)
return register(
host.session.hook("http", (event) => {
const request = event.request
const output = {
...event,
request: (input: Request) =>
Effect.runPromiseWith(context)(request(input), { signal: input.signal }),
}
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.request = (input) =>
Effect.tryPromise({
try: (signal) => output.request(new Request(input, { signal })),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}),
),
)
}),
)
},
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create(
+6 -16
View File
@@ -225,25 +225,15 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("http", (evt) =>
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const request = evt.request
evt.request = (input) => {
const url = new URL(input.url)
const headers = new Headers(input.headers)
headers.set("originator", "opencode")
headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
return request(
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
method: input.method,
headers,
body: input.body,
signal: input.signal,
}),
)
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
+13 -4
View File
@@ -220,15 +220,24 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: (request, handler) =>
transform: (request) =>
hooks
.trigger("session", "http", {
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: handler,
...request,
})
.pipe(Effect.flatMap((event) => event.request(request))),
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+37 -3
View File
@@ -7,6 +7,7 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model"
import { SessionEvent } from "./event"
import type { SessionSchema } from "./schema"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
@@ -444,6 +445,39 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function runAndTouch(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
yield* run(db, event)
yield* touchAncestors(db, event.data.sessionID, DateTime.toEpochMillis(event.created))
})
}
function touchAncestors(
db: DatabaseService,
sessionID: SessionSchema.ID,
updated: number,
seen = new Set<string>(),
): Effect.Effect<void> {
if (seen.has(sessionID)) return Effect.void
seen.add(sessionID)
return Effect.gen(function* () {
const session = yield* db
.select({ parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!session) return
yield* db
.update(SessionTable)
.set({ time_updated: updated })
.where(and(eq(SessionTable.id, sessionID), lt(SessionTable.time_updated, updated)))
.run()
.pipe(Effect.orDie)
if (session.parentID) yield* touchAncestors(db, session.parentID, updated, seen)
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@@ -678,9 +712,9 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)
+10 -16
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return providerError("provider.rate-limit", cause.reason)
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return providerError("provider.auth", cause.reason)
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return providerError("provider.quota", cause.reason)
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return providerError("provider.transport", cause.reason)
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return providerError("provider.internal", cause.reason)
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return providerError("provider.invalid-output", cause.reason)
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return providerError("provider.invalid-request", cause.reason)
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return providerError("provider.no-route", cause.reason)
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return providerError("provider.unknown", cause.reason)
return { type: "provider.unknown", message: cause.reason.message }
default: {
const exhaustive: never = cause.reason
return exhaustive
@@ -58,9 +58,3 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
+14 -85
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -148,9 +148,7 @@ describe("fromPromise", () => {
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code",
})
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
"Agent not found: missing",
)
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5",
@@ -223,77 +221,6 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = async (input) => {
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
return new Response(`${await response.text()}-response`)
}
})
},
}),
).effect(host)
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
}
yield* hooks.trigger("session", "http", event)
const response = yield* event.request(new Request("https://provider.test"))
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = (input) => request(input)
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
}
yield* hooks.trigger("session", "http", event)
const fiber = yield* event.request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -388,17 +315,19 @@ describe("fromPromise", () => {
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
tools.add(
{
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
},
})
)
})
},
})
@@ -29,21 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: (input) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
})
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -115,9 +100,33 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -125,7 +134,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator")
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -175,13 +184,21 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator")
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
-20
View File
@@ -14,9 +14,6 @@ import {
TransportReason,
UnknownProviderReason,
ToolFailure,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
} from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
@@ -74,23 +71,6 @@ describe("toSessionError", () => {
})
})
test("preserves provider HTTP status", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
})
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
type: "provider.invalid-request",
message: "too large",
status: 413,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
message: "bad gateway",
status: 502,
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq, sql } from "drizzle-orm"
import { asc, eq, inArray, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -679,6 +679,54 @@ describe("SessionProjector", () => {
}),
)
it.effect("rolls terminal execution activity through every session ancestor", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
const root = Session.ID.make("ses_activity_root")
const child = Session.ID.make("ses_activity_child")
const grandchild = Session.ID.make("ses_activity_grandchild")
yield* db
.insert(SessionTable)
.values(
[
{ id: root },
{ id: child, parent_id: root },
{ id: grandchild, parent_id: child },
].map((session) => ({
...session,
project_id: Project.ID.global,
slug: session.id,
directory: "/project",
title: "test",
version: "test",
time_updated: -1,
})),
)
.run()
.pipe(Effect.orDie)
yield* (yield* Bus.Service).publish(SessionEvent.Execution.Interrupted, {
sessionID: grandchild,
reason: "shutdown",
})
const rows = yield* db
.select({ id: SessionTable.id, updated: SessionTable.time_updated })
.from(SessionTable)
.where(inArray(SessionTable.id, [root, child, grandchild]))
.all()
.pipe(Effect.orDie)
expect(rows).toHaveLength(3)
expect(rows.every((row) => row.updated > -1)).toBe(true)
expect(new Set(rows.map((row) => row.updated)).size).toBe(1)
}),
)
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
+4 -4
View File
@@ -1,9 +1,10 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect, JsonSchema } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,16 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+3 -3
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -15,16 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Promise<Response>
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
-2
View File
@@ -1,11 +1,9 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
+44 -5
View File
@@ -68,6 +68,8 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { SessionInbox } from "./component/session-inbox"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "./context/session-tabs-model"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@@ -519,6 +521,8 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const inboxTabsEnabled = () =>
config.data.tabs?.layout === "inbox" && dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -645,6 +649,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
sessionTabs.navigation.blur()
route.navigate({
type: "home",
location:
@@ -653,6 +658,7 @@ function App(props: { pair?: DialogPairCredentials }) {
: undefined,
})
dialog.clear()
setTimeout(() => promptRef.current?.focus(), 0)
},
},
{
@@ -672,13 +678,35 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1),
})),
{
name: "session.tabs.toggle_layout",
title: !sessionTabs.enabled()
? "Enable inbox tabs"
: config.data.tabs?.layout === "inbox"
? "Move tabs to top"
: "Move tabs to inbox",
category: "Session",
slash: { name: "tabs", aliases: ["tab-layout"] },
run: () => {
void config
.update((draft) => {
draft.tabs = {
...draft.tabs,
enabled: true,
layout: sessionTabs.enabled() && config.data.tabs?.layout === "inbox" ? "top" : "inbox",
}
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "session.tab.next",
title: "Next tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1),
run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.previous",
@@ -686,7 +714,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.next_unread",
@@ -694,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1),
run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.previous_unread",
@@ -702,7 +730,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1),
run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.close",
@@ -724,7 +752,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i),
run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
})),
{
name: "model.list",
@@ -1214,12 +1242,23 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show
when={
sessionTabs.enabled() &&
inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionInbox />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show
when={
sessionTabs.enabled() &&
!inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
+34 -1
View File
@@ -54,6 +54,8 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "../../context/session-tabs-model"
registerOpencodeSpinner()
@@ -159,6 +161,7 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const sessionTabs = useSessionTabs()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
@@ -589,7 +592,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || props.disabled || dialog.stack.length > 0 || sessionTabs.navigation.active()) {
if (input.focused) input.blur()
return
}
@@ -810,6 +813,35 @@ export function Prompt(props: PromptProps) {
}
})
Keymap.createLayer(() => ({
priority: 2,
target: inputTarget,
enabled:
inputTarget() !== undefined &&
!props.disabled &&
store.mode === "normal" &&
!auto()?.visible &&
config.tabs?.layout === "inbox" &&
dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH &&
sessionTabs.enabled() &&
sessionTabs.tabs().length > 0 &&
store.prompt.text === "" &&
store.prompt.pasted.length === 0 &&
(store.prompt.files?.length ?? 0) === 0 &&
(store.prompt.agents?.length ?? 0) === 0,
commands: [
{
bind: "left",
title: "Focus session inbox",
group: "Session",
run: () => {
if (!sessionTabs.navigation.focus()) return false
input.blur()
},
},
],
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
@@ -1455,6 +1487,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
sessionTabs.navigation.blur()
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -0,0 +1,320 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { SessionMessageAssistant } from "@opencode-ai/client"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { usePromptRef } from "../context/prompt"
import { useSessionTabs } from "../context/session-tabs"
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { getScrollAcceleration } from "../util/scroll"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { activityVerb } from "../util/activity-verb"
import { markdownPreview } from "../util/markdown-preview"
import { Spinner } from "./spinner"
import type { SessionTabsStatus } from "./session-tabs"
const labels: Record<SessionInboxGroup, string> = {
running: "Active",
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier",
}
type SessionInboxRowInfo = {
sessionID: string
title: string
updated: number
preview: string
status: SessionTabsStatus
group: SessionInboxGroup
}
function SessionInboxRow(props: {
row: SessionInboxRowInfo
selected?: boolean
focused?: boolean
pendingDone?: boolean
number?: number
verb: string
onSelect?: () => void
}) {
const theme = useTheme("elevated")
const themes = useThemes()
const [hovered, setHovered] = createSignal(false)
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const background = () => {
if (props.focused) return theme.background.action.primary.hovered
if (props.selected) return theme.background.surface.offset
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const feedback = () => {
if (props.row.status.attention) return theme.text.feedback.warning.default
if (props.row.status.unread === "error") return theme.text.feedback.error.default
if (props.row.status.unread) return accent()
return theme.text.subdued
}
return (
<box
height={4}
id={`session-inbox-${props.row.sessionID}`}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
backgroundColor={background()}
border={["left"]}
borderColor={props.selected || props.focused ? accent() : background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={props.onSelect}
>
<box height={2} flexDirection="row">
<Show when={props.number}>
{(number) => (
<text
width={String(number()).length + 1}
fg={props.selected || props.focused ? accent() : theme.text.subdued}
selectable={false}
>
{number()}
</text>
)}
</Show>
<box height={2} flexGrow={1} minWidth={0}>
<box height={1} flexDirection="row">
<text
flexGrow={1}
flexShrink={1}
fg={theme.text.default}
attributes={props.selected || props.focused ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
selectable={false}
>
{props.row.title}
</text>
<Show when={props.row.status.attention || (!props.row.status.busy && props.row.status.unread)}>
<text width={2} fg={feedback()} selectable={false}>
</text>
</Show>
</box>
<box height={1}>
<Switch
fallback={
<text
height={1}
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
wrapMode="none"
truncate
selectable={false}
>
{props.row.preview}
</text>
}
>
<Match when={props.pendingDone}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Space again to mark done
</text>
</Match>
<Match when={props.row.status.attention}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Action required
</text>
</Match>
<Match when={props.row.status.busy}>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb}</span>
</Spinner>
</Match>
</Switch>
</box>
</box>
</box>
</box>
)
}
export function SessionInbox() {
const tabs = useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const themes = useThemes()
const config = useConfig().data
const prompt = usePromptRef()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
let scroll: ScrollBoxRenderable
const [verbCycle, setVerbCycle] = createSignal(0)
const [groupClock, setGroupClock] = createSignal(Date.now())
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
onCleanup(() => {
clearInterval(verbTimer)
clearInterval(groupTimer)
tabs.navigation.blur()
})
const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => {
return tabs
.recent()
.map((tab) => {
const session = data.session.get(tab.sessionID)
const status = tabs.status(tab.sessionID)
const assistant = data.session.message
.list(tab.sessionID)
.findLast(
(message): message is SessionMessageAssistant =>
message.type === "assistant" && (!session?.revert?.messageID || message.id < session.revert.messageID),
)
const preview = assistant?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
const updated = tabs.updated(tab.sessionID)
return {
sessionID: tab.sessionID,
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet",
status,
group: sessionInboxGroup(updated, status.busy, groupClock()),
}
})
})
const groups = createMemo(() =>
(["running", "today", "yesterday", "earlier"] as const)
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
.filter((group) => group.rows.length > 0),
)
const numbers = createMemo(() => new Map(rows().map((row, index) => [row.sessionID, index + 1])))
createEffect(() => {
if (!tabs.navigation.active()) return
const sessionID = tabs.navigation.selected()
if (!sessionID || !scroll || scroll.isDestroyed) return
const row = scroll.getRenderable(`session-inbox-${sessionID}`)
if (!row) return
const top = scroll.scrollTop + row.y - scroll.viewport.y
const bottom = top + row.height
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
})
const leave = () => {
tabs.navigation.blur()
prompt.current?.focus()
}
const newSession = () => {
keymap.dispatch("session.new")
}
Keymap.createLayer(() => ({
mode: "global",
priority: 2,
enabled: tabs.navigation.active,
commands: [
{
bind: "up,shift+tab",
title: "Previous session",
group: "Session",
run: () => tabs.navigation.move(-1),
},
{
bind: "down,tab",
title: "Next session",
group: "Session",
run: () => tabs.navigation.move(1),
},
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
{ bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
],
}))
return (
<box
width={width()}
height="100%"
flexShrink={0}
flexDirection="column"
paddingRight={1}
backgroundColor={theme.background.default}
>
<box height={3} flexShrink={0} paddingLeft={2} paddingRight={1} flexDirection="row" alignItems="center">
<text flexGrow={1} fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<text
fg={theme.text.subdued}
onMouseUp={newSession}
selectable={false}
>
opt+T
</text>
</box>
<scrollbox
ref={(value) => (scroll = value)}
flexGrow={1}
scrollAcceleration={getScrollAcceleration(config)}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
<box flexShrink={0} paddingBottom={1}>
<Show when={groups().length > 0} fallback={<text marginLeft={2} fg={theme.text.subdued}>No open sessions</text>}>
<For each={groups()}>
{(group) => (
<box flexShrink={0}>
<box height={2} paddingLeft={2} paddingRight={1} alignItems="center" flexDirection="row">
<text flexGrow={1} fg={group.group === "running" ? accent() : theme.text.subdued}>
{labels[group.group]}
</text>
<text fg={theme.text.subdued}>{group.rows.length}</text>
</box>
<For each={group.rows}>
{(row) => (
<SessionInboxRow
row={row}
selected={tabs.current() === row.sessionID}
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
number={numbers().get(row.sessionID)}
verb={activityVerb(row.sessionID, verbCycle())}
onSelect={() => {
tabs.navigation.blur()
tabs.select(row.sessionID)
}}
/>
)}
</For>
</box>
)}
</For>
</Show>
</box>
</scrollbox>
<box height={2} flexShrink={0} paddingLeft={2} alignItems="center">
<text fg={theme.text.subdued} wrapMode="none" truncate>
{tabs.navigation.active() ? "↑↓/tab choose · enter open · space done" : "← empty prompt · /tabs layout"}
</text>
</box>
</box>
)
}
+3
View File
@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
layout: Schema.optional(Schema.Literals(["top", "inbox"])).annotate({
description: "Show tabs as a top strip or a grouped inbox rail",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
+10 -10
View File
@@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_new: keybind("alt+t,<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
@@ -116,15 +116,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -22,6 +22,34 @@ export const SESSION_TAB_MIN_WIDTH = 8
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "12 " and " 12".
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
export const SESSION_INBOX_MIN_TERMINAL_WIDTH = 72
export function sessionInboxWidth(terminalWidth: number) {
return Math.max(28, Math.min(40, Math.floor(terminalWidth * 0.28)))
}
export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
if (running) return "running"
const today = new Date(now)
today.setHours(0, 0, 0, 0)
if (updated >= today.getTime()) return "today"
today.setDate(today.getDate() - 1)
if (updated >= today.getTime()) return "yesterday"
return "earlier"
}
export function orderSessionTabs(
tabs: readonly SessionTab[],
status: (sessionID: string) => { busy: boolean; updated: number },
) {
return tabs
.map((tab, index) => ({ tab, index, ...status(tab.sessionID) }))
.toSorted((a, b) => Number(b.busy) - Number(a.busy) || b.updated - a.updated || a.index - b.index)
.map((item) => item.tab)
}
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
if (index === -1) return [...tabs, tab]
+128 -11
View File
@@ -16,6 +16,7 @@ import {
moveSessionTabHistory,
NEW_SESSION_TAB_TITLE,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
@@ -61,6 +62,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
const [lastActivity, setLastActivity] = createSignal<Record<string, number>>({})
const [navigationActive, setNavigationActive] = createSignal(false)
const [navigationSelection, setNavigationSelection] = createSignal<string>()
const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
@@ -79,11 +84,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const root = (sessionID: string) => data.session.root(sessionID)
const updated = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return (members.length > 0 ? members : [session]).reduce(
(latest, id) =>
Math.max(
latest,
data.session.get(id)?.time.updated ?? 0,
lastActivity()[id] ?? 0,
),
0,
)
}
const touch = (sessionID: string, created: number) => {
if (!enabled()) return
if ((lastActivity()[sessionID] ?? 0) >= created) return
setLastActivity((value) => ({ ...value, [sessionID]: Math.max(value[sessionID] ?? 0, created) }))
}
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) => ({
const normalize = (value: TabsState): TabsState => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
@@ -114,6 +137,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
const recent = () =>
orderSessionTabs(state().tabs, (sessionID) => ({
busy: status(sessionID).busy,
updated: updated(sessionID),
}))
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
@@ -146,6 +174,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
createEffect(() => {
if (!navigationActive()) return
const tabs = state().tabs
if (tabs.length === 0) {
setNavigationActive(false)
setNavigationSelection(undefined)
setNavigationPendingDone(undefined)
return
}
if (tabs.some((tab) => tab.sessionID === navigationSelection())) return
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
@@ -182,7 +224,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
@@ -196,12 +238,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.execution.succeeded", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.interrupted", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.failed", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "error")
}),
)
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
touch(evt.data.sessionID, evt.created)
const sessionID = root(evt.data.sessionID)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
@@ -240,6 +298,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
setLastActivity((activity) => {
const family = data.session.family(target)
const members = family.length > 0 ? family : [target]
if (!members.some((id) => activity[id] !== undefined)) return activity
const next = { ...activity }
for (const id of members) delete next[id]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@@ -248,6 +314,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
recent,
updated,
newTab() {
return newTab()
},
@@ -291,25 +359,74 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = moveSessionTab(draft.tabs, session, index)
})
},
cycle(direction: 1 | -1) {
cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
const tab = cycleSessionTab(order === "recent" ? recent() : state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
(order === "recent" ? recent() : state().tabs).filter(
(tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention,
),
current(),
direction,
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
selectIndex(index: number) {
selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = state().tabs[index]
const tab = (order === "recent" ? recent() : state().tabs)[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
navigation: {
active: navigationActive,
selected: navigationSelection,
pendingDone: navigationPendingDone,
focus() {
if (!enabled() || state().tabs.length === 0) return false
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
setNavigationActive(true)
return true
},
blur() {
setNavigationActive(false)
setNavigationPendingDone(undefined)
},
move(direction: 1 | -1) {
const tabs = recent().map((tab) => tab.sessionID)
if (!navigationActive() || tabs.length === 0) return
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
setNavigationSelection(tabs[(start + direction + tabs.length) % tabs.length])
setNavigationPendingDone(undefined)
},
select() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
setNavigationActive(false)
setNavigationPendingDone(undefined)
route.navigate({ type: "session", sessionID })
},
done() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
if (navigationPendingDone() !== sessionID) {
setNavigationPendingDone(sessionID)
return
}
const tabs = recent().map((tab) => tab.sessionID)
const index = tabs.indexOf(sessionID)
const next = tabs[index + 1] ?? tabs[index - 1]
const selected = current() === sessionID
setNavigationPendingDone(undefined)
remove(sessionID, true)
setNavigationSelection(selected ? (current() ?? next) : next)
if (!next) setNavigationActive(false)
},
},
}
},
})
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
function View(props: { context: Plugin.Context; sessionID: string }) {
const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
@@ -18,32 +18,28 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
)
return (
<Show when={state() || cost() > 0}>
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<Show when={cost() > 0}>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</Show>
</box>
</Show>
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
)
}
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})
+13 -5
View File
@@ -73,6 +73,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH, sessionInboxWidth } from "../../context/session-tabs-model"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@@ -190,6 +192,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const sessionTabs = useSessionTabs()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
@@ -199,14 +202,20 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const wide = createMemo(() => dimensions().width > 120)
const viewportWidth = createMemo(() => {
const width = dimensions().width
if (!sessionTabs.enabled() || config.tabs?.layout !== "inbox" || width < SESSION_INBOX_MIN_TERMINAL_WIDTH)
return width
return width - sessionInboxWidth(width)
})
const wide = createMemo(() => viewportWidth() > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => viewportWidth() - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -281,7 +290,6 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -915,8 +923,8 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
// The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
// A deferred scroll here causes a visible second jump whenever tabs switch.
createEffect(
on(
() => route.sessionID,
@@ -27,11 +27,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
visible: false,
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
+67
View File
@@ -0,0 +1,67 @@
export const ACTIVITY_VERBS = [
"mogging",
"glazing",
"larping",
"canoodling",
"scheming",
"fuming",
"coping",
"seething",
"plotting",
"yapping",
"fumbling",
"malding",
"grandstanding",
"doomscrolling",
"clowning",
"gremlining",
"goblining",
"waffling",
"dillydallying",
"catastrophizing",
"overthinking",
"underdelivering",
"vibecoding",
"yak-shaving",
"bikeshedding",
"nitpicking",
"procrastinating",
"overengineering",
"spaghettiweaving",
"bugfarming",
"cachepoisoning",
"stacktracing",
"tokenburning",
"contextstuffing",
"promptwrangling",
"diffdivining",
"lintworshipping",
"testdodging",
"mergebegging",
"branchhaunting",
"commitcosplaying",
"semicolonhoarding",
"typesquinting",
"regexsummoning",
"dependencyjuggling",
"logstaring",
"packetwhispering",
"daemonpoking",
"terminalpeacocking",
"sandboxrattling",
"copypasting",
"tabcollecting",
"scopecreeping",
"deadlinehaunting",
"yakstacking",
"slopshoveling",
"codefermenting",
"pixelslandering",
"buildgaslighting",
"syntaxgrooming",
] as const
export function activityVerb(key: string, offset = 0) {
const hash = Array.from(key).reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0)
return ACTIVITY_VERBS[(hash + offset) % ACTIVITY_VERBS.length]
}
+17
View File
@@ -0,0 +1,17 @@
export function markdownPreview(input: string) {
return input
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
.replace(/^ {0,3}(?:#{1,6}\s+|>\s?|(?:[-+*]|\d+[.)])\s+)/gm, "")
.replace(/(?<!\\)~~(?=\S)([\s\S]*?\S)(?<!\\)~~/g, "$1")
.replace(/(?<!\\)\*\*(?=\S)([\s\S]*?\S)(?<!\\)\*\*/g, "$1")
.replace(/(?<!\\)__(?=\S)([\s\S]*?\S)(?<!\\)__/g, "$1")
.replace(/(?<![\\*])\*(?=\S)([^*\n]*?\S)(?<!\\)\*(?!\*)/g, "$1")
.replace(/(?<![\\_])_(?=\S)([^_\n]*?\S)(?<!\\)_(?!_)/g, "$1")
.replace(/\\([\\`*_[\]{}()#+\-.!>|~])/g, "$1")
.replace(/\s+/g, " ")
.trim()
}
+4 -1
View File
@@ -17,8 +17,11 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(decode({ tabs: { enabled: true, layout: "inbox" } })).toEqual({
tabs: { enabled: true, layout: "inbox" },
})
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
+3 -1
View File
@@ -117,9 +117,11 @@ test("navigates session tabs with leader arrows", () => {
test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,<leader>n" }])
expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1,alt+1" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
@@ -6,15 +6,49 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionInboxGroup,
sessionInboxWidth,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("orders running sessions first and completed sessions by recent update", () => {
const tabs = ["old", "running-old", "new", "running-new"].map((sessionID) => ({ sessionID }))
const state = {
old: { busy: false, updated: 10 },
"running-old": { busy: true, updated: 20 },
new: { busy: false, updated: 40 },
"running-new": { busy: true, updated: 30 },
}
expect(orderSessionTabs(tabs, (sessionID) => state[sessionID as keyof typeof state]).map((tab) => tab.sessionID)).toEqual([
"running-new",
"running-old",
"new",
"old",
])
})
test("groups inbox tabs by running state and local calendar day", () => {
const now = new Date(2026, 6, 31, 12).getTime()
expect(sessionInboxGroup(new Date(2026, 6, 20).getTime(), true, now)).toBe("running")
expect(sessionInboxGroup(new Date(2026, 6, 31, 1).getTime(), false, now)).toBe("today")
expect(sessionInboxGroup(new Date(2026, 6, 30, 1).getTime(), false, now)).toBe("yesterday")
expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
})
test("sizes the inbox within its minimum and maximum rails", () => {
expect(sessionInboxWidth(72)).toBe(28)
expect(sessionInboxWidth(120)).toBe(33)
expect(sessionInboxWidth(200)).toBe(40)
})
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
+146 -9
View File
@@ -49,22 +49,43 @@ function stateDir(prefix: string) {
return dir
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
type SessionFixture = { parentID?: string; title?: string; updated?: number }
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; sessions?: Record<string, SessionFixture> },
) {
const state = options?.state ?? stateDir("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,
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
fixture.parentID === parentID ? [session(id, fixture)] : [],
),
cursor: {},
})
}
const match = /^\/api\/session\/([^/]+)$/.exec(url.pathname)
if (!match) return
const id = match[1]!
const fixture = options?.sessions?.[id] ?? (id === initialSessionID ? { title: options?.title } : undefined)
if (!fixture) return
return json({ data: session(id, fixture) })
function session(id: string, fixture: SessionFixture) {
return {
id,
parentID: fixture.parentID,
title: fixture.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
time: { created: 0, updated: fixture.updated ?? 0 },
}
}
}, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
@@ -218,6 +239,68 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
}
})
test("tracks live inbox recency beyond cached session metadata", async () => {
const setup = await renderSessionTabs("background")
try {
expect(setup.tabs.updated("background")).toBe(0)
setup.emit({
id: "evt_admitted",
created: 100,
type: "session.input.admitted",
durable: { aggregateID: "background", seq: 1, version: 1 },
data: {
sessionID: "background",
inputID: "msg_1",
input: { type: "user", data: { text: "work" }, delivery: "steer" },
},
})
await wait(() => setup.tabs.updated("background") === 100)
setup.emit({
id: "evt_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "background", seq: 2, version: 1 },
data: { sessionID: "background" },
})
await wait(() => setup.tabs.updated("background") === 200)
} finally {
setup.destroy()
}
})
test("tracks child activity before family hydration and after restart", async () => {
const state = stateDir("opencode-session-tabs-recency-")
const sessions: Record<string, SessionFixture> = { child: { parentID: "parent" } }
const first = await renderSessionTabs("parent", { state, sessions })
try {
first.emit({
id: "evt_child_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
})
expect(first.tabs.updated("parent")).toBe(0)
await first.data.session.sync("child")
await wait(() => first.tabs.updated("parent") === 200)
} finally {
first.destroy()
}
sessions.child.updated = 200
const second = await renderSessionTabs("parent", { state, sessions })
try {
expect(second.tabs.updated("parent")).toBe(0)
await second.data.session.sync("parent", { children: true })
await wait(() => second.tabs.updated("parent") === 200)
} finally {
second.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
@@ -248,3 +331,57 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.destroy()
}
})
test("navigates the inbox without changing sessions and confirms done twice", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first")
expect(setup.tabs.navigation.focus()).toBe(true)
expect(setup.tabs.navigation.selected()).toBe("first")
setup.tabs.navigation.move(1)
expect(setup.tabs.navigation.selected()).toBe("second")
expect(setup.tabs.current()).toBe("first")
setup.tabs.navigation.done()
expect(setup.tabs.navigation.pendingDone()).toBe("second")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 1)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
expect(setup.tabs.current()).toBe("first")
expect(setup.tabs.navigation.selected()).toBe("first")
} finally {
setup.destroy()
}
})
test("keeps inbox focus aligned with history after marking the current session done", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second")
setup.route.navigate({ type: "session", sessionID: "third" })
await wait(() => setup.tabs.current() === "third")
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().length === 3)
setup.tabs.navigation.focus()
setup.tabs.navigation.done()
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 2)
expect(setup.tabs.current()).toBe("third")
expect(setup.tabs.navigation.selected()).toBe("third")
} finally {
setup.destroy()
}
})
@@ -1,70 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
function context(options?: { cost?: number; tokens?: number }) {
const color = RGBA.fromInts(200, 200, 200)
return {
theme: { text: { default: color, subdued: color } },
data: {
session: {
get: () => ({ location: { directory: "/workspace" } }),
cost: () => options?.cost ?? 0,
message: {
list: () =>
options?.tokens
? [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: {
input: options.tokens,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
]
: [],
},
},
location: {
model: { list: () => [] },
},
},
} as unknown as Context
}
test("sidebar omits context before usage is available", async () => {
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Context")
expect(app.captureCharFrame()).not.toContain("Not measured")
} finally {
app.renderer.destroy()
}
})
test("sidebar shows available context usage", async () => {
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Context")
expect(app.captureCharFrame()).toContain("1,234 tokens")
} finally {
app.renderer.destroy()
}
})
+37
View File
@@ -139,3 +139,40 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("dispatches direct and leader tab-number bindings", async () => {
const calls: number[] = []
function Harness() {
Keymap.createLayer(() => ({
mode: "global",
commands: Array.from({ length: 2 }, (_, index) => ({
id: `session.tab.select.${index + 1}`,
run: () => void calls.push(index + 1),
})),
}))
Keymap.createLayer(() => ({
mode: "global",
bindings: ["session.tab.select.1", "session.tab.select.2"],
}))
return <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
app.mockInput.pressKey("1", { ctrl: true })
expect(calls).toEqual([])
app.mockInput.pressKey("1", { meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("2")
expect(calls).toEqual([1, 2])
} finally {
app.renderer.destroy()
}
})
@@ -0,0 +1,9 @@
import { expect, test } from "bun:test"
import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
test("rotates through 60 stable activity verbs", () => {
expect(ACTIVITY_VERBS).toHaveLength(60)
expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
})
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test"
import { markdownPreview } from "../../src/util/markdown-preview"
test("strips common Markdown presentation syntax from previews", () => {
expect(markdownPreview("The capture shows **Reset** and a _short-lived_ **override**.")).toBe(
"The capture shows Reset and a short-lived override.",
)
expect(markdownPreview("Use [`sessionRename`](https://example.com) with ~~old~~ new behavior.")).toBe(
"Use sessionRename with old new behavior.",
)
expect(markdownPreview("![diagram](image.png) Review [the result][result].")).toBe("diagram Review the result.")
})
test("flattens block syntax and code without losing its content", () => {
expect(markdownPreview("# Result\n\n- First item\n- Second `item`\n> Finished")).toBe(
"Result First item Second item Finished",
)
expect(markdownPreview("```ts\nconst answer = 42\n```")).toBe("const answer = 42")
})
test("preserves literal asterisks, globs, and escaped punctuation", () => {
expect(markdownPreview("Search **/*.ts, calculate 2 * 3, and keep \\*literal\\*. ")).toBe(
"Search **/*.ts, calculate 2 * 3, and keep *literal*.",
)
})
+2 -3
View File
@@ -246,8 +246,7 @@ mutable fields:
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
@@ -260,7 +259,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("context", (event) => {
await ctx.session.hook("request", (event) => {
delete event.tools.write
})