mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ffc7fec1 | |||
| 330ab9acae | |||
| 9abd9594de | |||
| 22d2012f75 | |||
| cc883058df | |||
| 8ebc4c6f85 | |||
| b0ed9990b3 | |||
| e1c04dcce6 | |||
| 188c642d8c | |||
| 9d55b223bb | |||
| ce7a7e4e23 | |||
| cba5ba03e3 | |||
| 49081a4e24 | |||
| d6371f2fcd | |||
| 7625cbdf47 | |||
| 0ffec67ca3 | |||
| bd132f2614 | |||
| dcb0df4ac1 | |||
| 02f3504055 | |||
| f23ee5e4a8 | |||
| 77aa85c589 | |||
| a618946b7e | |||
| f9de608dea |
@@ -143,7 +143,10 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { XAIImages } from "../protocols/xai-images"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import type { OpenAIOptionsInput } from "./openai-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = ProviderOptions & {
|
||||
readonly xai?: OpenAIOptionsInput
|
||||
}
|
||||
|
||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||
|
||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { xai: { store: false } } },
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "openai-compatible-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||
|
||||
const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return OpenAIResponses.route.with({
|
||||
return responsesRoute.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -40,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
|
||||
const configuredChatRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return OpenAICompatibleChat.route.with({
|
||||
return chatRoute.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
|
||||
export const configure = (input: ModelOptions = {}) => {
|
||||
const responsesRoute = configuredResponsesRoute(input)
|
||||
const chatRoute = configuredChatRoute(input)
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
@@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
|
||||
|
||||
const model = XAI.provider.model("grok-4")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
||||
providerOptions: { openai: { reasoningEffort: true } },
|
||||
providerOptions: { xai: { reasoningEffort: true } },
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
providerOptions: { xai: { reasoningEffort: "high" } },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
@@ -57,7 +57,7 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
||||
}
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
|
||||
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
|
||||
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
|
||||
@@ -153,4 +153,42 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("forgets withdrawn input before a later promotion", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_withdrawn",
|
||||
type: "session.input.withdrawn",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.withdrawn":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -370,6 +370,15 @@ export type Endpoint5_26Output =
|
||||
readonly input: SessionPending.Message
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.withdrawn"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -841,6 +850,12 @@ export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messa
|
||||
export type Endpoint5_29Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_30Output = boolean
|
||||
export type SessionPendingWithdrawOperation<E = never> = (
|
||||
input: Endpoint5_30Input,
|
||||
) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
@@ -865,7 +880,10 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly withdraw: SessionPendingWithdrawOperation<E>
|
||||
}
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
|
||||
@@ -76,6 +76,8 @@ import type {
|
||||
Endpoint5_28Output,
|
||||
Endpoint5_29Input,
|
||||
Endpoint5_29Output,
|
||||
Endpoint5_30Input,
|
||||
Endpoint5_30Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -550,6 +552,14 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint5_0(raw),
|
||||
create: Endpoint5_1(raw),
|
||||
@@ -570,7 +580,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
wait: Endpoint5_16(raw),
|
||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||
context: Endpoint5_20(raw),
|
||||
pending: { list: Endpoint5_21(raw) },
|
||||
pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_30(raw) },
|
||||
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||
generate: Endpoint5_25(raw),
|
||||
log: Endpoint5_26(raw),
|
||||
|
||||
@@ -70,6 +70,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionPendingWithdrawInput,
|
||||
SessionPendingWithdrawOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -732,6 +734,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
withdraw: (input: SessionPendingWithdrawInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionPendingWithdrawOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/withdraw`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
instructions: {
|
||||
entry: {
|
||||
|
||||
@@ -637,6 +637,16 @@ export type SessionInputPromoted = {
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputWithdrawn = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.withdrawn"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2157,6 +2167,7 @@ export type SessionEventDurable =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputWithdrawn
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2252,6 +2263,7 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputWithdrawn
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -3207,6 +3219,13 @@ export type SessionMessageInput = {
|
||||
|
||||
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
|
||||
|
||||
export type SessionPendingWithdrawInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingWithdrawOutput = { data: boolean }["data"]
|
||||
|
||||
export type MessageListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
|
||||
@@ -300,7 +300,7 @@ test("session instructions methods use the public HTTP contract", async () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("session.pending.list uses the public HTTP contract", async () => {
|
||||
test("session.pending uses the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const pending = [
|
||||
{
|
||||
@@ -317,14 +317,22 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
return Response.json({ data: pending })
|
||||
return Response.json({ data: request.method === "GET" ? pending : true })
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.session.pending.list({ sessionID: "ses_test" })
|
||||
const withdrawn = await client.session.pending.withdraw({ sessionID: "ses_test", inputID: "msg_pending" })
|
||||
|
||||
expect(result).toEqual(pending)
|
||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||
expect(withdrawn).toBe(true)
|
||||
expect(requests).toEqual([
|
||||
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" },
|
||||
{
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/api/session/ses_test/pending/msg_pending/withdraw",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
|
||||
@@ -54,9 +54,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
||||
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
|
||||
@@ -18,7 +18,6 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly experimentalOxfmt?: boolean
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
|
||||
@@ -201,7 +201,7 @@ const layer = Layer.effect(
|
||||
locks.withLock(repository.gitDirectory)(effect)
|
||||
|
||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
|
||||
@@ -182,7 +182,8 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !native) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
@@ -193,20 +194,22 @@ export const fromCatalogModel = (
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
const specifier = native
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const providerOptions = nativeProviderOptions(packageName, configured)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
@@ -223,6 +226,26 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
const nativePackage = (packageName: string | undefined) => {
|
||||
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
|
||||
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
|
||||
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nativeProviderOptions = (packageName: string | undefined, settings: Readonly<Record<string, unknown>>) => {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(values).length === 0) return undefined
|
||||
if (packageName === "@ai-sdk/google") return { gemini: values }
|
||||
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
|
||||
if (packageName === "@ai-sdk/xai") return { xai: values }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
@@ -11,8 +11,6 @@ import { Permission } from "../permission"
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
const BUILD_SYSTEM =
|
||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
input: AbsolutePath,
|
||||
) {
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
@@ -224,7 +224,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
@@ -246,12 +246,13 @@ const layer = Layer.effect(
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
const canonical = yield* git.worktree
|
||||
.list(repo)
|
||||
.pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
const canonical =
|
||||
repo.gitDirectory === repo.commonDirectory
|
||||
? repo.worktree
|
||||
: yield* git.worktree.list(repo).pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
|
||||
@@ -209,6 +209,10 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly withdraw: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
inputID: SessionMessage.ID
|
||||
}) => Effect.Effect<boolean, NotFoundError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
@@ -540,6 +544,10 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionPending.list(db, sessionID)
|
||||
}),
|
||||
withdraw: Effect.fn("Session.withdraw")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* SessionPending.withdraw(db, bus, input)
|
||||
}),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
|
||||
@@ -175,6 +175,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
"session.input.admitted": () => Effect.void,
|
||||
"session.input.withdrawn": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionPending from "./pending"
|
||||
|
||||
import { and, asc, eq, or } from "drizzle-orm"
|
||||
import { and, asc, eq, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import {
|
||||
Compaction,
|
||||
@@ -38,10 +38,15 @@ const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
||||
const decodeWithdrawnEvent = Schema.decodeUnknownOption(SessionEvent.InputWithdrawn.data)
|
||||
const admittedEventType = Bus.versionedType(
|
||||
SessionEvent.InputAdmitted.type,
|
||||
SessionEvent.InputAdmitted.durable.version,
|
||||
)
|
||||
const withdrawnEventType = Bus.versionedType(
|
||||
SessionEvent.InputWithdrawn.type,
|
||||
SessionEvent.InputWithdrawn.durable.version,
|
||||
)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||
@@ -103,6 +108,38 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
||||
return entry.type === "compaction" ? entry : undefined
|
||||
})
|
||||
|
||||
const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
id: SessionMessage.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, sessionID),
|
||||
eq(EventTable.type, admittedEventType),
|
||||
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row) {
|
||||
const decoded = decodeAdmittedEvent(row.data)
|
||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id)
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const base = { id, sessionID, timeCreated: DateTime.makeUnsafe(row.created) }
|
||||
return decoded.value.input.type === "user"
|
||||
? User.make({ ...base, ...decoded.value.input })
|
||||
: Synthetic.make({ ...base, ...decoded.value.input })
|
||||
}
|
||||
// A projected message without an admitted event in this aggregate (for
|
||||
// example fork-copied history) is not a retryable admission.
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Reconstruct the admitted record for a pending row that was already consumed
|
||||
* by promotion. The projected `session_message` row proves promotion happened;
|
||||
@@ -123,27 +160,30 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
|
||||
if (message === undefined) return undefined
|
||||
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const rows = yield* db
|
||||
.select()
|
||||
return yield* admittedFromHistory(db, sessionID, id)
|
||||
})
|
||||
|
||||
const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
id: SessionMessage.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
||||
.all()
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, sessionID),
|
||||
eq(EventTable.type, withdrawnEventType),
|
||||
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const decoded = decodeAdmittedEvent(row.data)
|
||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
||||
const base = {
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(row.created),
|
||||
}
|
||||
return decoded.value.input.type === "user"
|
||||
? User.make({ ...base, ...decoded.value.input })
|
||||
: Synthetic.make({ ...base, ...decoded.value.input })
|
||||
}
|
||||
// A projected message without an admitted event in this aggregate (for
|
||||
// example fork-copied history) is not a retryable admission.
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
if (!row) return false
|
||||
const decoded = decodeWithdrawnEvent(row.data)
|
||||
return decoded._tag === "Some" && decoded.value.inputID === id
|
||||
})
|
||||
|
||||
export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||
@@ -162,6 +202,8 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||
}
|
||||
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
||||
if (promoted !== undefined) return promoted
|
||||
if (yield* wasWithdrawn(db, request.sessionID, request.id))
|
||||
return yield* admittedFromHistory(db, request.sessionID, request.id)
|
||||
return yield* bus
|
||||
.publish(SessionEvent.InputAdmitted, {
|
||||
inputID: request.id,
|
||||
@@ -309,14 +351,13 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
||||
* message insert inside the same event transaction; the deleted row is what
|
||||
* makes the table pending-only.
|
||||
*/
|
||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||
const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
) {
|
||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
|
||||
@@ -329,6 +370,27 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
return stored
|
||||
})
|
||||
|
||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
) {
|
||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return yield* consumeInput(db, input)
|
||||
})
|
||||
|
||||
export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
) {
|
||||
return yield* consumeInput(db, input)
|
||||
})
|
||||
|
||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID },
|
||||
@@ -406,6 +468,30 @@ export const equivalent = (
|
||||
return false
|
||||
}
|
||||
|
||||
export const withdraw = Effect.fn("SessionPending.withdraw")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
input: { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID },
|
||||
) {
|
||||
return yield* inboxLocks.withLock(input.sessionID)(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* find(db, input.inputID)
|
||||
if (!pending) return yield* wasWithdrawn(db, input.sessionID, input.inputID)
|
||||
if (pending.sessionID !== input.sessionID || pending.type === "compaction") return false
|
||||
yield* bus
|
||||
.publish(SessionEvent.InputWithdrawn, input)
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
wasWithdrawn(db, input.sessionID, input.inputID).pipe(
|
||||
Effect.flatMap((withdrawn) => (withdrawn ? Effect.void : Effect.die(defect))),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
|
||||
@@ -657,6 +657,18 @@ const layer = Layer.effectDiscard(
|
||||
input: event.data.input,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputWithdrawn, (event) =>
|
||||
SessionPending.projectWithdrawn(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -267,6 +267,33 @@ describe("FSUtil", () => {
|
||||
expect(result).toContain(path.join(tmp, "b.txt"))
|
||||
}),
|
||||
)
|
||||
|
||||
it(
|
||||
"stops at the first match when requested",
|
||||
Effect.gen(function* () {
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
||||
const child = path.join(tmp, "sub")
|
||||
yield* filesys.makeDirectory(child)
|
||||
yield* filesys.writeFileString(path.join(child, "marker"), "child")
|
||||
const checked: string[] = []
|
||||
const instrumented = FileSystem.FileSystem.of({
|
||||
...filesys,
|
||||
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
|
||||
})
|
||||
const search = yield* FSUtil.Service.pipe(
|
||||
Effect.provide(
|
||||
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
|
||||
path.join(child, "marker"),
|
||||
])
|
||||
expect(checked).toEqual([path.join(child, "marker")])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("glob", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
|
||||
package: Provider.aisdk("@ai-sdk/google"),
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
})
|
||||
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
@@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
|
||||
let requests = 0
|
||||
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
|
||||
fetch: async () => {
|
||||
requests += 1
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 1,
|
||||
maxReconnectionDelay: 1,
|
||||
reconnectionDelayGrowFactor: 1,
|
||||
maxRetries: 2,
|
||||
},
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
|
||||
await Bun.sleep(25)
|
||||
await transport.close()
|
||||
|
||||
expect(requests).toBe(1)
|
||||
})
|
||||
|
||||
test("skips MCP resource requests when the capability is absent", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -546,6 +546,76 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const packages = [
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-model")
|
||||
expect(settings).toMatchObject({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://provider.example/v1",
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads supported AISDK catalog packages as native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const google = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
|
||||
)
|
||||
const openrouter = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
settings: { reasoning: { effort: "high" } },
|
||||
}),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
|
||||
expect(google.route.id).toBe("gemini")
|
||||
expect(google.route.defaults.providerOptions).toEqual({
|
||||
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
expect(openrouter.route.id).toBe("openrouter")
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
xai: { reasoningEffort: "high", store: false },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -554,8 +624,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
modelID: "gemini-api-model",
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
modelID: "mistral-api-model",
|
||||
settings: { project: "test" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -566,9 +636,9 @@ describe("ModelResolver", () => {
|
||||
Effect.sync(() => {
|
||||
expect(runtime).toMatchObject({
|
||||
id: "test-model",
|
||||
modelID: "gemini-api-model",
|
||||
modelID: "mistral-api-model",
|
||||
providerID: "test-provider",
|
||||
package: Provider.aisdk("@ai-sdk/google"),
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
settings: { project: "test", apiKey: "fallback-secret" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -582,15 +652,15 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
|
||||
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://mistral.example/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
@@ -598,9 +668,9 @@ describe("ModelResolver", () => {
|
||||
_tag: "SessionRunnerModel.UnsupportedPackageError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
package: "aisdk:@ai-sdk/mistral",
|
||||
})
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -612,8 +682,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { apiKey: "", baseURL: "https://google.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("Project.resolve", () => {
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
|
||||
@@ -171,6 +171,30 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders sessions by their latest prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const active = yield* session.create({ location, title: "active" })
|
||||
const newer = yield* session.create({ location, title: "newer" })
|
||||
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: -2, time_updated: -2 })
|
||||
.where(eq(SessionTable.id, active.id))
|
||||
.run()
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: -1, time_updated: -1 })
|
||||
.where(eq(SessionTable.id, newer.id))
|
||||
.run()
|
||||
|
||||
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
|
||||
|
||||
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters direct child sessions by parent ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -996,6 +996,52 @@ describe("Session.pending", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("withdraws an interrupted input before promotion without resurrecting exact retries", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
|
||||
const admitted = yield* session.prompt({
|
||||
id: SessionMessage.ID.make("msg_withdrawn"),
|
||||
sessionID,
|
||||
text: "Withdraw me",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.interrupt(sessionID)
|
||||
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
|
||||
|
||||
const retried = yield* session.prompt({
|
||||
id: admitted.id,
|
||||
sessionID,
|
||||
text: "Withdraw me",
|
||||
resume: false,
|
||||
})
|
||||
expect(retried.id).toBe(admitted.id)
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputWithdrawn.type, 1))).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves promoted input for revert when withdrawal loses the race", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const admitted = yield* session.prompt({ sessionID, text: "Promote me", resume: false })
|
||||
|
||||
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||
|
||||
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(false)
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: admitted.id, type: "user" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists an unhandled compaction barrier until it settles", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -628,6 +628,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.pending.withdraw", "/api/session/:sessionID/pending/:inputID/withdraw", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: Schema.Struct({ data: Schema.Boolean }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.withdraw",
|
||||
summary: "Withdraw pending session input",
|
||||
description:
|
||||
"Withdraw one admitted input before promotion. Returns true when the input was withdrawn or had already been withdrawn, and false when it is no longer pending so callers can fall back to reverting projected history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
|
||||
@@ -155,6 +155,16 @@ export const InputAdmitted = Event.durable({
|
||||
})
|
||||
export type InputAdmitted = typeof InputAdmitted.Type
|
||||
|
||||
export const InputWithdrawn = Event.durable({
|
||||
type: "session.input.withdrawn",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
})
|
||||
export type InputWithdrawn = typeof InputWithdrawn.Type
|
||||
|
||||
export namespace Execution {
|
||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||
export type Started = typeof Started.Type
|
||||
@@ -556,6 +566,7 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
InputPromoted,
|
||||
InputAdmitted,
|
||||
InputWithdrawn,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
|
||||
@@ -108,6 +108,7 @@ describe("public event manifest", () => {
|
||||
"session.forked.2",
|
||||
"session.input.promoted.1",
|
||||
"session.input.admitted.1",
|
||||
"session.input.withdrawn.1",
|
||||
"session.execution.started.1",
|
||||
"session.execution.succeeded.1",
|
||||
"session.execution.failed.1",
|
||||
|
||||
@@ -608,6 +608,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.pending.withdraw",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.withdraw(ctx.params).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.instructions.entry.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
|
||||
...options,
|
||||
width: cols,
|
||||
height: rows,
|
||||
kittyKeyboard: Boolean(options.useKittyKeyboard),
|
||||
...(recording
|
||||
? {
|
||||
stdout: recording as unknown as NodeJS.WriteStream,
|
||||
|
||||
@@ -43,6 +43,25 @@ test("normalizes named keys for OpenTUI", async () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("headless input mirrors the configured kitty keyboard protocol", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
|
||||
const harness = createHarness(renderer)
|
||||
let key: { readonly name: string; readonly source: string } | undefined
|
||||
renderer.keyInput.once("keypress", (event) => {
|
||||
key = event
|
||||
})
|
||||
|
||||
yield* execute(harness, { type: "ui.press", key: "escape" })
|
||||
|
||||
expect(key).toMatchObject({ name: "escape", source: "kitty" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("clicks a target at relative coordinates through descendant text", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogProject } from "./component/dialog-project"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
@@ -104,6 +105,7 @@ const sessionTabBindingCommands = [
|
||||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
"session.tab.reopen",
|
||||
"session.tab.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
@@ -648,6 +650,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project.switch",
|
||||
title: "Switch project",
|
||||
category: "Session",
|
||||
slash: { name: "projects", aliases: ["project"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogProject />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -658,7 +669,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
})),
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next open session tab",
|
||||
title: "Next tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -666,7 +677,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
title: "Previous open session tab",
|
||||
title: "Previous tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -674,7 +685,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.back",
|
||||
title: "Back in session tab history",
|
||||
title: "Back in tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -682,7 +693,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.forward",
|
||||
title: "Forward in session tab history",
|
||||
title: "Forward in tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -690,7 +701,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
title: "Next unread session tab",
|
||||
title: "Next unread tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -698,7 +709,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
title: "Previous unread session tab",
|
||||
title: "Previous unread tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -706,14 +717,21 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
title: "Close current session tab",
|
||||
title: "Close tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
{
|
||||
name: "session.tab.reopen",
|
||||
title: "Reopen closed tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.reopen(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to session tab ${i + 1}`,
|
||||
title: `Switch to tab ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -1209,7 +1227,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { go } from "../logo"
|
||||
|
||||
const PERIOD = 4600
|
||||
const RINGS = 3
|
||||
const WIDTH = 3.8
|
||||
const TAIL = 9.5
|
||||
const AMP = 0.55
|
||||
const TAIL_AMP = 0.16
|
||||
const BREATH_AMP = 0.05
|
||||
const BREATH_SPEED = 0.0008
|
||||
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
|
||||
const PHASE_OFFSET = 0.29
|
||||
const LOGO_GAP = 1
|
||||
const LOGO_TOP_BIAS = -1
|
||||
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
|
||||
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
|
||||
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
|
||||
const LOGO_HEIGHT = LOGO_LINES.length
|
||||
const SPACE = " ".codePointAt(0)!
|
||||
const TOP_HALF = "▀".codePointAt(0)!
|
||||
const FULL_BLOCK = "█".codePointAt(0)!
|
||||
const RING_SCALE = 1 / RINGS
|
||||
const TAIL_SCALE = 1 / TAIL
|
||||
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
|
||||
|
||||
const enum LogoCellKind {
|
||||
Background,
|
||||
Top,
|
||||
ShadowTop,
|
||||
Solid,
|
||||
Char,
|
||||
}
|
||||
|
||||
type LogoTemplateCell = {
|
||||
x: number
|
||||
y: number
|
||||
kind: LogoCellKind
|
||||
charCode: number
|
||||
attributes: number
|
||||
topDist: number
|
||||
bottomDist: number
|
||||
}
|
||||
|
||||
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
|
||||
Array.from(line)
|
||||
.map((char, x) => {
|
||||
if (char === " ") return
|
||||
const kind =
|
||||
char === "_"
|
||||
? LogoCellKind.Background
|
||||
: char === "^"
|
||||
? LogoCellKind.Top
|
||||
: char === "~"
|
||||
? LogoCellKind.ShadowTop
|
||||
: char === "█"
|
||||
? LogoCellKind.Solid
|
||||
: LogoCellKind.Char
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
kind,
|
||||
charCode: char.codePointAt(0) ?? SPACE,
|
||||
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
|
||||
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
|
||||
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
|
||||
}
|
||||
})
|
||||
.filter((cell): cell is LogoTemplateCell => !!cell),
|
||||
)
|
||||
|
||||
export type Rgb = [number, number, number]
|
||||
|
||||
export type GoUpsellArtRenderOptions = {
|
||||
deltaTime?: number
|
||||
rgb?: boolean
|
||||
cache?: boolean
|
||||
}
|
||||
|
||||
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
|
||||
const CACHE_FRAMES_PER_RENDER = 1
|
||||
|
||||
export function toRgb(color: RGBA): Rgb {
|
||||
const [r, g, b] = color.toInts()
|
||||
return [r, g, b]
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
|
||||
buffer[offset] = r
|
||||
buffer[offset + 1] = g
|
||||
buffer[offset + 2] = b
|
||||
buffer[offset + 3] = a
|
||||
}
|
||||
|
||||
function mixChannel(base: number, overlay: number, alpha: number) {
|
||||
return Math.round(base + (overlay - base) * clamp(alpha))
|
||||
}
|
||||
|
||||
function writeLogoTint(
|
||||
buffer: Uint16Array,
|
||||
offset: number,
|
||||
base: Rgb,
|
||||
primary: Rgb,
|
||||
primaryMix: number,
|
||||
peakMix: number,
|
||||
) {
|
||||
const p = clamp(primaryMix)
|
||||
const q = clamp(peakMix)
|
||||
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
|
||||
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
|
||||
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
|
||||
writeRgb(buffer, offset, r, g, b)
|
||||
}
|
||||
|
||||
function sameRgb(a: Rgb, b: Rgb) {
|
||||
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
|
||||
}
|
||||
|
||||
export class GoUpsellArtPainter {
|
||||
private panelRgb: Rgb = [0, 0, 0]
|
||||
private primaryRgb: Rgb = [255, 255, 255]
|
||||
private logoBaseRgb: Rgb = [180, 180, 180]
|
||||
private elapsed = 0
|
||||
private distances = new Float32Array(0)
|
||||
private edgeFalloff = new Float32Array(0)
|
||||
private geometryWidth = 0
|
||||
private geometryHeight = 0
|
||||
private reach = 1
|
||||
private logoX = 0
|
||||
private logoY = 0
|
||||
private logoIndexes = new Int32Array(0)
|
||||
private logoRgb: boolean | undefined
|
||||
private pulsePeak = 0
|
||||
private pulsePrimary = 0
|
||||
private cacheDirty = true
|
||||
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
|
||||
private cacheBuildIndex = 0
|
||||
|
||||
setBackgroundPanel(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.panelRgb, next)) return false
|
||||
this.panelRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setLogoBase(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.logoBaseRgb, next)) return false
|
||||
this.logoBaseRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setPrimary(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.primaryRgb, next)) return false
|
||||
this.primaryRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
|
||||
const rgb = options.rgb === true
|
||||
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
|
||||
this.rebuildGeometry(frameBuffer, rgb)
|
||||
if (options.cache !== false) {
|
||||
this.drawCached(frameBuffer, rgb)
|
||||
return
|
||||
}
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
}
|
||||
|
||||
private invalidateCache() {
|
||||
this.cacheDirty = true
|
||||
this.cacheBuildIndex = 0
|
||||
this.frameCache = []
|
||||
}
|
||||
|
||||
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const width = frameBuffer.width
|
||||
const height = frameBuffer.height
|
||||
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
|
||||
const logoTemplateChanged = this.logoRgb !== rgb
|
||||
if (!geometryChanged && !logoTemplateChanged) return
|
||||
|
||||
if (geometryChanged) {
|
||||
this.geometryWidth = width
|
||||
this.geometryHeight = height
|
||||
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
|
||||
this.logoY = Math.max(
|
||||
0,
|
||||
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
|
||||
)
|
||||
|
||||
const centerX = this.logoX + LOGO_WIDTH / 2
|
||||
const centerY = this.logoY + LOGO_HEIGHT / 2
|
||||
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
|
||||
this.distances = new Float32Array(width * height)
|
||||
this.edgeFalloff = new Float32Array(width * height)
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const index = y * width + x
|
||||
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
|
||||
this.distances[index] = dist
|
||||
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logoRgb = rgb
|
||||
this.invalidateCache()
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
}
|
||||
|
||||
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
|
||||
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
|
||||
this.buildFrameCache(frameBuffer, rgb)
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
|
||||
if (frame) {
|
||||
frameBuffer.buffers.fg.set(frame.fg)
|
||||
frameBuffer.buffers.bg.set(frame.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
this.frameCache = []
|
||||
this.cacheBuildIndex = 0
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
this.cacheDirty = false
|
||||
}
|
||||
|
||||
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
|
||||
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
|
||||
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
|
||||
this.drawBackground(frameBuffer, t)
|
||||
this.drawLogo(frameBuffer, t, rgb)
|
||||
this.frameCache.push({
|
||||
fg: new Uint16Array(frameBuffer.buffers.fg),
|
||||
bg: new Uint16Array(frameBuffer.buffers.bg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const buffers = frameBuffer.buffers
|
||||
buffers.char.fill(SPACE)
|
||||
buffers.attributes.fill(0)
|
||||
|
||||
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
|
||||
this.logoIndexes = new Int32Array(0)
|
||||
return
|
||||
}
|
||||
|
||||
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
|
||||
this.logoIndexes[i] = index
|
||||
buffers.attributes[index] = cell.attributes
|
||||
buffers.char[index] =
|
||||
cell.kind === LogoCellKind.Background
|
||||
? SPACE
|
||||
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
|
||||
? TOP_HALF
|
||||
: cell.kind === LogoCellKind.Solid
|
||||
? rgb
|
||||
? TOP_HALF
|
||||
: FULL_BLOCK
|
||||
: cell.charCode
|
||||
}
|
||||
}
|
||||
|
||||
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const distances = this.distances
|
||||
const edgeFalloff = this.edgeFalloff
|
||||
const baseR = this.panelRgb[0]
|
||||
const baseG = this.panelRgb[1]
|
||||
const baseB = this.panelRgb[2]
|
||||
const deltaR = this.primaryRgb[0] - baseR
|
||||
const deltaG = this.primaryRgb[1] - baseG
|
||||
const deltaB = this.primaryRgb[2] - baseB
|
||||
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||
|
||||
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
|
||||
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const envelope2 = Math.sin(phase2 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
|
||||
const head0 = phase0 * this.reach
|
||||
const head1 = phase1 * this.reach
|
||||
const head2 = phase2 * this.reach
|
||||
|
||||
for (let index = 0; index < distances.length; index++) {
|
||||
const dist = distances[index]
|
||||
const delta0 = dist - head0
|
||||
const abs0 = delta0 < 0 ? -delta0 : delta0
|
||||
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
|
||||
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const abs1 = delta1 < 0 ? -delta1 : delta1
|
||||
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
|
||||
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta2 = dist - head2
|
||||
const abs2 = delta2 < 0 ? -delta2 : delta2
|
||||
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
|
||||
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const level =
|
||||
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
|
||||
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
|
||||
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
|
||||
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
|
||||
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
|
||||
const offset = index * 4
|
||||
const r = Math.round(baseR + deltaR * strength)
|
||||
const g = Math.round(baseG + deltaG * strength)
|
||||
const b = Math.round(baseB + deltaB * strength)
|
||||
bg[offset] = fg[offset] = r
|
||||
bg[offset + 1] = fg[offset + 1] = g
|
||||
bg[offset + 2] = fg[offset + 2] = b
|
||||
bg[offset + 3] = fg[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
|
||||
let peak = 0.04
|
||||
let primary = 0
|
||||
|
||||
const delta0 = dist - head0
|
||||
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
|
||||
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
|
||||
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
|
||||
peak += core0 * 0.65 * eased0
|
||||
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
|
||||
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
|
||||
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
|
||||
peak += core1 * 0.65 * eased1
|
||||
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
|
||||
|
||||
this.pulsePeak = peak > 1 ? 1 : peak
|
||||
this.pulsePrimary = primary > 1 ? 1 : primary
|
||||
}
|
||||
|
||||
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
|
||||
if (this.logoIndexes.length === 0) return
|
||||
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const shadow: Rgb = [
|
||||
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
|
||||
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
|
||||
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
|
||||
]
|
||||
const phase0 = (t / PERIOD) % 1
|
||||
const phase1 = (t / PERIOD + 0.5) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const head0 = phase0 * LOGO_REACH
|
||||
const head1 = phase1 * LOGO_REACH
|
||||
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = this.logoIndexes[i]!
|
||||
const offset = index * 4
|
||||
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
|
||||
const topPeak = this.pulsePeak
|
||||
const topPrimary = this.pulsePrimary
|
||||
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
|
||||
const bottomPeak = this.pulsePeak
|
||||
const bottomPrimary = this.pulsePrimary
|
||||
|
||||
if (cell.kind === LogoCellKind.Background) {
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Top) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.ShadowTop) {
|
||||
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Solid && rgb) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
|
||||
continue
|
||||
}
|
||||
|
||||
writeLogoTint(
|
||||
fg,
|
||||
offset,
|
||||
this.logoBaseRgb,
|
||||
this.primaryRgb,
|
||||
(topPrimary + bottomPrimary) / 2,
|
||||
(topPeak + bottomPeak) / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
FrameBufferRenderable,
|
||||
RGBA,
|
||||
type OptimizedBuffer,
|
||||
type RenderContext,
|
||||
type RenderableOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
|
||||
backgroundPanel?: RGBA
|
||||
primary?: RGBA
|
||||
logoBase?: RGBA
|
||||
}
|
||||
|
||||
class GoUpsellArtRenderable extends FrameBufferRenderable {
|
||||
private painter = new GoUpsellArtPainter()
|
||||
|
||||
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
|
||||
const width = typeof options.width === "number" ? options.width : 1
|
||||
const height = typeof options.height === "number" ? options.height : 1
|
||||
super(ctx, {
|
||||
...options,
|
||||
width,
|
||||
height,
|
||||
live: options.live ?? true,
|
||||
respectAlpha: false,
|
||||
})
|
||||
|
||||
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
|
||||
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
|
||||
this.painter.setBackgroundPanel(options.backgroundPanel)
|
||||
this.painter.setPrimary(options.primary)
|
||||
this.painter.setLogoBase(options.logoBase)
|
||||
}
|
||||
|
||||
set backgroundPanel(value: RGBA | undefined) {
|
||||
if (this.painter.setBackgroundPanel(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set logoBase(value: RGBA | undefined) {
|
||||
if (this.painter.setLogoBase(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set primary(value: RGBA | undefined) {
|
||||
if (this.painter.setPrimary(value)) this.requestRender()
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
|
||||
if (!this.visible || this.isDestroyed) return
|
||||
|
||||
this.painter.render(this.frameBuffer, {
|
||||
deltaTime,
|
||||
rgb: this._ctx.capabilities?.rgb === true,
|
||||
})
|
||||
super.renderSelf(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
go_upsell_art: typeof GoUpsellArtRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
|
||||
onMount(() => {
|
||||
targetFps = renderer.targetFps
|
||||
maxFps = renderer.maxFps
|
||||
renderer.targetFps = 30
|
||||
renderer.maxFps = 30
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.targetFps = targetFps
|
||||
renderer.maxFps = maxFps
|
||||
})
|
||||
|
||||
return (
|
||||
<go_upsell_art
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundPanel={theme.background.default}
|
||||
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from "path"
|
||||
import { createMemo } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function DialogProject() {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(toast.error)
|
||||
|
||||
const current = () => location.current?.project
|
||||
|
||||
const options = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => {
|
||||
if (a.id === current()?.id) return -1
|
||||
if (b.id === current()?.id) return 1
|
||||
return 0
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const description = abbreviateHome(project.canonical, paths.home)
|
||||
// Dialog padding, the current marker, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
description: truncateFilePath(description, width),
|
||||
searchText: description,
|
||||
value: project.canonical,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Switch project"
|
||||
placeholder="Search projects…"
|
||||
options={options()}
|
||||
current={current()?.canonical}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text>No projects found</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value === current()?.canonical) return
|
||||
const target = { directory: option.value }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
const PAD_TOP_OUTER = 1
|
||||
const FOREGROUND_ALPHA = 186
|
||||
|
||||
export type DialogRetryActionProps = {
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
link?: string
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
}
|
||||
|
||||
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
if (props.link) open(props.link).catch(() => {})
|
||||
props.onClose?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
props.onClose?.(true)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function panelOverlay(color: RGBA) {
|
||||
const [r, g, b] = color.toInts()
|
||||
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
|
||||
}
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "tab",
|
||||
title: "Next retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "return",
|
||||
title: "Confirm retry option",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
if (selected() === "action") runAction(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box>
|
||||
{showGoTreatment() ? (
|
||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||
<BgPulse />
|
||||
</box>
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={theme.text.subdued} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
<box paddingBottom={1} />
|
||||
)}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{props.label}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogRetryAction.show = (
|
||||
dialog: DialogContext,
|
||||
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
|
||||
) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
|
||||
export function DialogSessionDeleteFailed(props: {
|
||||
session: string
|
||||
workspace: string
|
||||
onDelete?: () => boolean | void | Promise<boolean | void>
|
||||
onRestore?: () => boolean | void | Promise<boolean | void>
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "delete" as const,
|
||||
title: "Delete workspace",
|
||||
description: "Delete the workspace and all sessions attached to it.",
|
||||
run: props.onDelete,
|
||||
},
|
||||
{
|
||||
id: "restore" as const,
|
||||
title: "Restore to new workspace",
|
||||
description: "Try to restore this session into a new workspace.",
|
||||
run: props.onRestore,
|
||||
},
|
||||
]
|
||||
|
||||
async function confirm() {
|
||||
const result = await options.find((item) => item.id === store.active)?.run?.()
|
||||
if (result === false) return
|
||||
props.onDone?.()
|
||||
if (!props.onDone) dialog.clear()
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
|
||||
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
|
||||
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
|
||||
{
|
||||
bind: "right",
|
||||
title: "Restore broken session",
|
||||
group: "Dialog",
|
||||
run: () => setStore("active", "restore"),
|
||||
},
|
||||
{
|
||||
bind: "down",
|
||||
title: "Restore broken session",
|
||||
group: "Dialog",
|
||||
run: () => setStore("active", "restore"),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -133,9 +133,7 @@ export function DialogSessionList() {
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: directory !== data.location.info()?.project.directory
|
||||
? Locale.truncate(path.basename(directory), 20)
|
||||
: ""
|
||||
: undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: store.filter,
|
||||
type: "file",
|
||||
limit: 5,
|
||||
location: {
|
||||
directory: data.location.default().directory,
|
||||
workspace: data.location.default().workspaceID,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return result?.data.map((item) => item.path) ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sessionTabComplete,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
@@ -24,10 +25,19 @@ type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
unread: SessionTabUnread | undefined
|
||||
}
|
||||
export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
|
||||
unread: undefined,
|
||||
promptPulse: 0,
|
||||
attention: false,
|
||||
busy: false,
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
|
||||
newTab?: () => boolean
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: "New session" }
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -42,8 +52,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const activeID = createMemo(tabs.current)
|
||||
const items = tabs.tabs
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
@@ -51,7 +62,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
() =>
|
||||
new Map(
|
||||
layout().tabs.map((tab) => {
|
||||
const status = tabs.status(tab.sessionID)
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
@@ -262,6 +273,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
@@ -275,6 +287,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
|
||||
}}
|
||||
@@ -283,6 +296,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={status().busy && !status().attention}
|
||||
promptPulse={status().promptPulse}
|
||||
complete={status().complete && !status().attention}
|
||||
glow={glows()}
|
||||
breathe={status().attention}
|
||||
@@ -322,7 +336,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extend } from "@opentui/solid"
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
enabled?: boolean
|
||||
active?: boolean
|
||||
promptPulse?: number
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
breathe?: boolean
|
||||
@@ -28,6 +29,7 @@ const COMPLETION_OPACITY = 0.18
|
||||
const EDGE_FLASH_DURATION = 800
|
||||
const EDGE_FLASH_ATTACK = 0.1
|
||||
const EDGE_FLASH_OPACITY = 0.1
|
||||
const PROMPT_FLASH_SCALE = 2
|
||||
const GLOW_IGNITION_DURATION = 600
|
||||
const GLOW_IGNITION_PEAK = 1.5
|
||||
const GLOW_IGNITION_ATTACK = 0.3
|
||||
@@ -102,6 +104,11 @@ class Envelope {
|
||||
this.scale = scale
|
||||
}
|
||||
|
||||
restart(scale = 1) {
|
||||
this.clock = 0
|
||||
this.scale = scale
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.clock = undefined
|
||||
}
|
||||
@@ -127,6 +134,7 @@ const envelopeActive = (envelope: Envelope) => envelope.active
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private _active: boolean
|
||||
private _promptPulse: number
|
||||
private _complete: boolean
|
||||
private _glow: boolean
|
||||
private _breathe: boolean
|
||||
@@ -154,6 +162,7 @@ class TabPulseRenderable extends Renderable {
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
this._enabled = enabled
|
||||
this._active = active
|
||||
this._promptPulse = options.promptPulse ?? 0
|
||||
this._complete = options.complete ?? false
|
||||
this._glow = options.glow ?? false
|
||||
this._breathe = options.breathe ?? false
|
||||
@@ -222,6 +231,15 @@ class TabPulseRenderable extends Renderable {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set promptPulse(value: number) {
|
||||
if (value === this._promptPulse) return
|
||||
this._promptPulse = value
|
||||
if (!this._enabled) return
|
||||
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
@@ -368,6 +386,7 @@ extend({ tab_pulse: TabPulseRenderable })
|
||||
export function TabPulse(props: {
|
||||
enabled?: boolean
|
||||
active: boolean
|
||||
promptPulse?: number
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
breathe?: boolean
|
||||
@@ -385,6 +404,7 @@ export function TabPulse(props: {
|
||||
width="100%"
|
||||
enabled={props.enabled ?? true}
|
||||
active={props.active}
|
||||
promptPulse={props.promptPulse ?? 0}
|
||||
complete={props.complete ?? false}
|
||||
glow={props.glow ?? false}
|
||||
breathe={props.breathe ?? false}
|
||||
|
||||
@@ -127,13 +127,13 @@ export const Info = Schema.Struct({
|
||||
tabs: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
|
||||
}),
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share session tabs globally or keep a separate set for each working directory",
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session tab settings" }),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
|
||||
@@ -87,13 +87,14 @@ export const Definitions = {
|
||||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current session tab"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current tab"),
|
||||
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
@@ -118,15 +119,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 session tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 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"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -297,6 +298,7 @@ export const CommandMap = {
|
||||
session_tab_next_unread: "session.tab.next_unread",
|
||||
session_tab_previous_unread: "session.tab.previous_unread",
|
||||
session_tab_close: "session.tab.close",
|
||||
session_tab_reopen: "session.tab.reopen",
|
||||
session_timeline: "session.timeline",
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
|
||||
@@ -188,6 +188,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
if (item) index.set(item.id, position)
|
||||
}
|
||||
},
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
@@ -395,8 +401,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
existing.time.created = event.created
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
setStore(
|
||||
"session",
|
||||
@@ -438,6 +443,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
break
|
||||
case "session.input.withdrawn": {
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
draft.splice(position, 1)
|
||||
index.delete(event.data.inputID)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
const instructions = event.metadata?.instructions
|
||||
if (
|
||||
@@ -1089,6 +1113,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
list(location?: LocationRef) {
|
||||
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||
},
|
||||
listBySession(sessionID: string) {
|
||||
return Object.values(store.location)
|
||||
.flatMap((data) => Object.values(data.shell ?? {}))
|
||||
.filter((shell) => shell.metadata.sessionID === sessionID)
|
||||
},
|
||||
get(id: string) {
|
||||
return Object.values(store.location)
|
||||
.map((data) => data.shell?.[id])
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useData } from "./data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
export function useDirectory() {
|
||||
const data = useData()
|
||||
const paths = useTuiPaths()
|
||||
return createMemo(() => {
|
||||
const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
|
||||
return abbreviateHome(directory, paths.home)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import type { LocationRef } from "@opencode-ai/client"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../prompt/history"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
@@ -6,6 +7,7 @@ import { useTuiStartup } from "./runtime"
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
prompt?: PromptInfo
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
|
||||
@@ -36,6 +36,39 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
|
||||
}
|
||||
}
|
||||
|
||||
export type ClosedSessionTab = {
|
||||
tab: SessionTab
|
||||
index: number
|
||||
}
|
||||
|
||||
const CLOSED_SESSION_TAB_LIMIT = 10
|
||||
|
||||
export function recordClosedSessionTab(
|
||||
stack: readonly ClosedSessionTab[],
|
||||
tab: SessionTab,
|
||||
index: number,
|
||||
): ClosedSessionTab[] {
|
||||
return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
|
||||
-CLOSED_SESSION_TAB_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recently closed tab that is not already open and restore it at its original
|
||||
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
|
||||
*/
|
||||
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
|
||||
const remaining = [...stack]
|
||||
while (remaining.length > 0) {
|
||||
const entry = remaining.pop()!
|
||||
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
|
||||
const next = [...tabs]
|
||||
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
|
||||
return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
|
||||
}
|
||||
return { stack: remaining, tabs: undefined, sessionID: undefined }
|
||||
}
|
||||
|
||||
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
|
||||
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
const to = Math.max(0, Math.min(tabs.length - 1, index))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
moveSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
type ClosedSessionTab,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
@@ -55,7 +58,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
key: "sessionID",
|
||||
})
|
||||
const fallback = empty()
|
||||
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
|
||||
function state() {
|
||||
if (config.tabs?.scope === "global") return store.global
|
||||
@@ -77,6 +83,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const family = members.length > 0 ? members : [session]
|
||||
return {
|
||||
unread: state().unread[session],
|
||||
promptPulse: promptPulses()[session] ?? 0,
|
||||
attention: family.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
@@ -176,6 +183,14 @@ 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.input.admitted", (evt) => {
|
||||
if (!enabled() || evt.data.input.type !== "user") return
|
||||
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 }))
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.error", (evt) => {
|
||||
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||
@@ -183,6 +198,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
const target = root(evt.data.sessionID)
|
||||
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
@@ -201,6 +218,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
draft.tabs = closeSessionTab(draft.tabs, target).tabs
|
||||
delete draft.unread[target]
|
||||
})
|
||||
setPromptPulses((pulses) => {
|
||||
if (pulses[target] === undefined) return pulses
|
||||
const next = { ...pulses }
|
||||
delete next[target]
|
||||
return next
|
||||
})
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
}
|
||||
|
||||
@@ -209,6 +232,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
tabs() {
|
||||
return state().tabs
|
||||
},
|
||||
newTab() {
|
||||
return route.data.type === "home"
|
||||
},
|
||||
current,
|
||||
status,
|
||||
select(sessionID: string) {
|
||||
@@ -219,12 +245,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
if (!target) {
|
||||
const previous = state().tabs.at(-1)
|
||||
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
|
||||
history = previous.history
|
||||
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
|
||||
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
|
||||
return
|
||||
}
|
||||
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
|
||||
const tab = state().tabs[index]
|
||||
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
|
||||
remove(target, true)
|
||||
},
|
||||
reopen() {
|
||||
if (!enabled()) return
|
||||
const result = reopenSessionTab(closedTabs, state().tabs)
|
||||
closedTabs = result.stack
|
||||
const tabs = result.tabs
|
||||
if (!tabs || !result.sessionID) return
|
||||
update((draft) => {
|
||||
draft.tabs = tabs
|
||||
})
|
||||
route.navigate({ type: "session", sessionID: result.sessionID })
|
||||
},
|
||||
move(sessionID: string, index: number) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { FadeFilePath } from "../../ui/fade-file-path"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const directory = createMemo(() =>
|
||||
@@ -10,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
<FadeFilePath
|
||||
value={directory()}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={props.context.theme.text.subdued}
|
||||
bg={props.context.theme.background.default}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal, For, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
|
||||
import {
|
||||
EMPTY_SESSION_TAB_STATUS,
|
||||
SessionTabs,
|
||||
type SessionTabsController,
|
||||
} from "../../../component/session-tabs"
|
||||
import { moveSessionTab } from "../../../context/session-tabs-model"
|
||||
import type { Story } from "./index"
|
||||
|
||||
@@ -23,7 +27,6 @@ const FIXTURE_TABS = [
|
||||
{ sessionID: "fixture-12", title: "Prepare review" },
|
||||
]
|
||||
|
||||
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
|
||||
const RUN_DURATION = 1_800
|
||||
const RESUME_DURATION = 900
|
||||
|
||||
@@ -66,7 +69,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
if (!resumed && roll < 0.25) {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), attention: true },
|
||||
}))
|
||||
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
|
||||
return
|
||||
@@ -77,7 +80,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
|
||||
}))
|
||||
// An untitled session earns its title after its first completed run, like a real summarization.
|
||||
const index = number(sessionID) - 1
|
||||
@@ -110,7 +113,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
tabs,
|
||||
current: active,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_STATUS
|
||||
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
|
||||
},
|
||||
select,
|
||||
move(sessionID: string, index: number) {
|
||||
@@ -150,7 +153,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
const startRun = (sessionID: string) => {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
|
||||
}))
|
||||
setOutcomes((current) => {
|
||||
const next = { ...current }
|
||||
@@ -223,7 +226,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
|
||||
const selectedState = () => {
|
||||
const current = active()
|
||||
const status = current ? controller.status(current) : EMPTY_STATUS
|
||||
const status = current ? controller.status(current) : EMPTY_SESSION_TAB_STATUS
|
||||
const activity = status.busy
|
||||
? "running"
|
||||
: status.unread === "activity"
|
||||
@@ -351,7 +354,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
|
||||
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued}>
|
||||
space/s run | t add | d close | r reset | ←/→ 1-0 move | drag reorders | esc back
|
||||
@@ -363,6 +366,6 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
|
||||
export const sessionTabsStory: Story = {
|
||||
id: "session-tabs",
|
||||
title: "Session tabs",
|
||||
title: "Tabs",
|
||||
render: (context) => <SessionTabsStory context={context} />,
|
||||
}
|
||||
|
||||
@@ -643,6 +643,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.withdrawn") {
|
||||
child.prompts.delete(event.data.inputID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.promoted") {
|
||||
const prompt = child.prompts.get(event.data.inputID)
|
||||
if (prompt === undefined) return
|
||||
|
||||
@@ -911,6 +911,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.withdrawn") {
|
||||
state.admitted.delete(event.data.inputID)
|
||||
state.pending.delete(event.data.inputID)
|
||||
syncPending()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.promoted") {
|
||||
const waiting = state.wait?.messageID === event.data.inputID
|
||||
if (state.wait) promoteWait(state.wait, true, event.data.inputID)
|
||||
|
||||
@@ -18,6 +18,8 @@ export const builtins = [
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Storybook,
|
||||
// The storybook is a development tool; keep its route and palette commands out of
|
||||
// normal launches and register it only for OPENCODE_STORY runs.
|
||||
...(process.env.OPENCODE_STORY ? [Storybook] : []),
|
||||
DiffViewer,
|
||||
]
|
||||
|
||||
@@ -2,8 +2,10 @@ import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
@@ -372,13 +374,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
return true
|
||||
}
|
||||
|
||||
const reconcile = async () => {
|
||||
const reconcile = async (configured = config.data.plugins ?? []) => {
|
||||
await Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
)
|
||||
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
|
||||
batch(() => {
|
||||
setStore("registrations", reconcileStore({}))
|
||||
setStore("states", [])
|
||||
@@ -452,8 +454,21 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
])
|
||||
}
|
||||
}
|
||||
let loading = Promise.resolve()
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
() => {
|
||||
const configured = config.data.plugins ?? []
|
||||
loading = loading.catch(() => undefined).then(() => reconcile(configured))
|
||||
void loading.then(
|
||||
() => setStore("ready", true),
|
||||
() => setStore("ready", true),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
onMount(() => {
|
||||
const loading = reconcile()
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
if (disposing) return disposing
|
||||
@@ -474,7 +489,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
unregister()
|
||||
void dispose()
|
||||
})
|
||||
void loading.finally(() => setStore("ready", true))
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,10 +27,11 @@ export function Home() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
|
||||
const currentLocation = () => route.location ?? data.location.default()
|
||||
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
|
||||
let sent = false
|
||||
|
||||
createEffect(() => location.set(data.location.default()))
|
||||
createEffect(() => location.set(currentLocation()))
|
||||
|
||||
onMount(() => {
|
||||
editor.clearSelection()
|
||||
|
||||
@@ -16,9 +16,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && 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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { undoMessage } from "./undo"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
@@ -42,9 +43,10 @@ export function DialogMessage(props: {
|
||||
pasted: [],
|
||||
})
|
||||
}
|
||||
void client.api.session.revert
|
||||
.stage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
void undoMessage(client.api, {
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
||||
export function DialogSubagent(props: { sessionID: string }) {
|
||||
const route = useRoute()
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Subagent Actions"
|
||||
options={[
|
||||
{
|
||||
title: "Open",
|
||||
value: "subagent.view",
|
||||
description: "the subagent's session",
|
||||
onSelect: (dialog) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useData } from "../../context/data"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useConnected } from "../../component/use-connected"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { usePermission } from "../../context/permission"
|
||||
|
||||
export function Footer() {
|
||||
const theme = useTheme()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const permission = usePermission()
|
||||
const mcp = createMemo(
|
||||
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
|
||||
)
|
||||
const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
|
||||
const permissions = createMemo(() => {
|
||||
if (route.data.type !== "session") return []
|
||||
return data.session.permission.list(route.data.sessionID) ?? []
|
||||
})
|
||||
const directory = useDirectory()
|
||||
const connected = useConnected()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
welcome: false,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Track all timeouts to ensure proper cleanup
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
if (!store.welcome) {
|
||||
setStore("welcome", true)
|
||||
timeouts.push(setTimeout(() => tick(), 5000))
|
||||
return
|
||||
}
|
||||
|
||||
if (store.welcome) {
|
||||
setStore("welcome", false)
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
return
|
||||
}
|
||||
}
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={theme.text.subdued}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={store.welcome}>
|
||||
<text fg={theme.text.default}>
|
||||
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permission.mode !== "auto" && permissions().length > 0}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
<span style={{ fg: theme.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.text.feedback.success.default }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { undoMessage } from "./undo"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
@@ -590,9 +591,10 @@ export function Session() {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void client.api.session.revert
|
||||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
void undoMessage(client.api, {
|
||||
sessionID: route.sessionID,
|
||||
messageID: message.id,
|
||||
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt()?.set({
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "../../context/route"
|
||||
import { useData } from "../../context/data"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
const data = useData()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
|
||||
const subagentInfo = createMemo(() => {
|
||||
const s = session()
|
||||
if (!s) return "Subagent"
|
||||
const agentMatch = s.title.match(/@(\w+) subagent/)
|
||||
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const cost = current.cost
|
||||
const formattedCost = cost > 0 ? money.format(cost) : undefined
|
||||
const context = contextUsage(
|
||||
data.session.message.list(route.sessionID),
|
||||
data.location.model.list(current.location),
|
||||
current.revert?.messageID,
|
||||
)
|
||||
|
||||
return {
|
||||
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
||||
const theme = useTheme("elevated")
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||
backgroundColor={
|
||||
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
export function undoMessage(
|
||||
client: OpenCodeClient,
|
||||
input: { readonly sessionID: string; readonly messageID: string },
|
||||
) {
|
||||
const revert = () => client.session.revert.stage(input).then(() => undefined)
|
||||
return client.session.pending
|
||||
.withdraw({ sessionID: input.sessionID, inputID: input.messageID })
|
||||
.then((withdrawn) => (withdrawn ? undefined : revert()))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { tint } from "../theme/color"
|
||||
import { createAnimatable, tween } from "./animation"
|
||||
import { FilePath } from "./file-path"
|
||||
|
||||
// FilePath that crossfades when its value changes: the old path fades to the
|
||||
// background, the text swaps at the midpoint, and the new path fades back in.
|
||||
// The initial value renders immediately; only subsequent changes animate.
|
||||
export function FadeFilePath(props: {
|
||||
value: string | undefined
|
||||
maxWidth: number
|
||||
fg: RGBA
|
||||
bg: RGBA
|
||||
basenameFg?: RGBA
|
||||
}) {
|
||||
const config = useConfig().data
|
||||
const fade = createAnimatable(
|
||||
{ front: 1 },
|
||||
{
|
||||
enabled: () => config.animations ?? true,
|
||||
transition: tween({ duration: 0.3 }),
|
||||
},
|
||||
)
|
||||
const [text, setText] = createSignal(props.value)
|
||||
const [previous, setPrevious] = createSignal<string>()
|
||||
|
||||
createEffect((current: string | undefined) => {
|
||||
const next = props.value
|
||||
if (next === undefined || next === current) return current
|
||||
setText(next)
|
||||
if (current === undefined) return next
|
||||
setPrevious(current)
|
||||
fade.jump({ front: 0 })
|
||||
fade.animate({ front: 1 })
|
||||
return next
|
||||
}, props.value)
|
||||
|
||||
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
|
||||
const fg = createMemo(() => {
|
||||
if (previous() === undefined || fade.value().front >= 1) return props.fg
|
||||
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={display() !== undefined}>
|
||||
<FilePath
|
||||
value={display() ?? ""}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={fg()}
|
||||
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -62,9 +62,14 @@ export function truncateFilePath(value: string, maxWidth: number) {
|
||||
const separatorWidth = stringWidth(separator)
|
||||
let width = stringWidth(prefix + basename)
|
||||
for (let index = segments.length - 2; index >= 0; index--) {
|
||||
const next = stringWidth(segments[index]!) + separatorWidth
|
||||
if (width + next > maxWidth) break
|
||||
selected.unshift(segments[index]!)
|
||||
const segment = segments[index]!
|
||||
const next = stringWidth(segment) + separatorWidth
|
||||
if (width + next > maxWidth) {
|
||||
const available = maxWidth - width - separatorWidth
|
||||
if (available > 1) selected.unshift(takeStart(segment, available - 1) + "…")
|
||||
break
|
||||
}
|
||||
selected.unshift(segment)
|
||||
width += next
|
||||
}
|
||||
return prefix + selected.join(separator)
|
||||
|
||||
@@ -853,6 +853,65 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("removes optimistic input when it is withdrawn", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-withdrawal"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_admitted",
|
||||
created: 1,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-user",
|
||||
input: { type: "user", data: { text: "Never mind" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.message.get(sessionID, "message-user") !== undefined)
|
||||
expect(data.session.input.has(sessionID, "message-user")).toBe(true)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_withdrawn",
|
||||
created: 2,
|
||||
type: "session.input.withdrawn",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
|
||||
await wait(() => data.session.message.get(sessionID, "message-user") === undefined)
|
||||
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
|
||||
expect(data.session.pending.list(sessionID)).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies live tool rows independently of their call ID", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-tool-call-id"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { undoMessage } from "../../../src/routes/session/undo"
|
||||
|
||||
test.each([
|
||||
{ withdrawn: true, expected: ["withdraw"] },
|
||||
{ withdrawn: false, expected: ["withdraw", "revert"] },
|
||||
])("routes undo for withdrawn=$withdrawn", async ({ withdrawn, expected }) => {
|
||||
const calls: string[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const operation = request.url.endsWith("/withdraw") ? "withdraw" : "revert"
|
||||
calls.push(operation)
|
||||
return Response.json({ data: operation === "withdraw" ? withdrawn : { messageID: "msg_user" } })
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
),
|
||||
})
|
||||
|
||||
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user" })
|
||||
|
||||
expect(calls).toEqual([...expected])
|
||||
})
|
||||
+48
@@ -1,6 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
TabPulse,
|
||||
blendTabPulseColor,
|
||||
completionPulseOpacity,
|
||||
glowIgnitionLevel,
|
||||
@@ -8,6 +12,50 @@ import {
|
||||
} from "../../src/component/tab-pulse"
|
||||
import { tint } from "../../src/theme/color"
|
||||
|
||||
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const flash = RGBA.fromHex("#f0f0f0")
|
||||
const [promptPulse, setPromptPulse] = createSignal(0)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
active={true}
|
||||
promptPulse={promptPulse()}
|
||||
color={background}
|
||||
flashColor={flash}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(1)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
|
||||
|
||||
await Bun.sleep(800)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(2)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
@@ -106,8 +106,10 @@ test("opens the subagent picker with down", () => {
|
||||
test("navigates session tabs with leader arrows", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
|
||||
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
|
||||
])
|
||||
expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
|
||||
expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
|
||||
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
moveSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabOverflowWidth,
|
||||
@@ -136,6 +138,45 @@ describe("session tabs", () => {
|
||||
expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
|
||||
})
|
||||
|
||||
test("reopens the most recently closed tab at its original position", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
|
||||
const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
|
||||
|
||||
expect(reopened.sessionID).toBe("b")
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
|
||||
expect(reopened.stack).toEqual([])
|
||||
expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
|
||||
})
|
||||
|
||||
test("skips and consumes closed entries that are already open", () => {
|
||||
const stack = [
|
||||
{ tab: { sessionID: "a" }, index: 0 },
|
||||
{ tab: { sessionID: "b" }, index: 1 },
|
||||
]
|
||||
const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
|
||||
|
||||
expect(reopened.sessionID).toBe("a")
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
|
||||
expect(reopened.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("clamps restored positions and keeps one entry per session", () => {
|
||||
const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
|
||||
expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
|
||||
|
||||
const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
|
||||
|
||||
const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
|
||||
(stack, tab, index) => recordClosedSessionTab(stack, tab, index),
|
||||
twice,
|
||||
)
|
||||
expect(overflow).toHaveLength(10)
|
||||
expect(overflow.at(-1)?.tab.sessionID).toBe("11")
|
||||
expect(overflow[0]?.tab.sessionID).toBe("2")
|
||||
})
|
||||
|
||||
test("reveals completion activity only after session work becomes idle", () => {
|
||||
expect(sessionTabComplete("activity", true)).toBe(false)
|
||||
expect(sessionTabComplete("activity", false)).toBe(true)
|
||||
@@ -151,12 +192,12 @@ describe("session tabs", () => {
|
||||
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
|
||||
})
|
||||
|
||||
test("does not reserve an active tab slot on the new session page", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
|
||||
const layout = adaptiveSessionTabLayout(tabs, "dummy", 40)
|
||||
test("reserves an active tab slot for the new session page", () => {
|
||||
const tabs = ["a", "b", "c", "d", "new"].map((sessionID) => ({ sessionID }))
|
||||
const layout = adaptiveSessionTabLayout(tabs, "new", 54)
|
||||
|
||||
expect(layout.tabs).toEqual(tabs)
|
||||
expect(layout.widths).toEqual([8, 8, 8, 8, 8])
|
||||
expect(layout.widths).toEqual([8, 8, 8, 8, 22])
|
||||
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
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 } 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 { StorageProvider } from "../../src/context/storage"
|
||||
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, timeout = 2_000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSessionTabs(initialSessionID: string) {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
let tabs!: ReturnType<typeof useSessionTabs>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
tabs = useSessionTabs()
|
||||
route = useRoute()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={{ state }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: initialSessionID }}>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
return {
|
||||
tabs,
|
||||
route,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.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 => ({
|
||||
id: `evt_${inputID}`,
|
||||
created: Date.now(),
|
||||
type: "session.input.admitted",
|
||||
durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inputID,
|
||||
input: { type: "user", data: { text: inputID }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
|
||||
setup.route.navigate({ type: "session", sessionID: "active" })
|
||||
await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_context",
|
||||
created: Date.now(),
|
||||
type: "session.input.admitted",
|
||||
durable: { aggregateID: "background", seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: "background",
|
||||
inputID: "msg_context",
|
||||
input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await Bun.sleep(20)
|
||||
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,
|
||||
)
|
||||
|
||||
setup.emit(admitted("background", "msg_2"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 2)
|
||||
|
||||
setup.emit(admitted("active", "msg_3"))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.tabs.status("active").promptPulse).toBe(0)
|
||||
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks a temporary new session tab across close and creation", 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")
|
||||
|
||||
setup.route.navigate({ type: "home" })
|
||||
await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
|
||||
setup.tabs.close()
|
||||
await wait(() => setup.route.data.type === "session")
|
||||
|
||||
expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
|
||||
|
||||
setup.route.navigate({ type: "home" })
|
||||
await wait(() => setup.tabs.newTab())
|
||||
setup.route.navigate({ type: "session", sessionID: "third" })
|
||||
await wait(
|
||||
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
|
||||
)
|
||||
|
||||
expect(setup.tabs.newTab()).toBe(false)
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,11 @@ describe("truncateFilePath", () => {
|
||||
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
|
||||
})
|
||||
|
||||
test("uses remaining width for part of a long parent segment", () => {
|
||||
const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files"
|
||||
expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files")
|
||||
})
|
||||
|
||||
test("preserves the extension when the basename must shrink", () => {
|
||||
expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx")
|
||||
expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx")
|
||||
|
||||
@@ -28,6 +28,13 @@ export namespace FSUtil {
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
|
||||
export interface UpOptions {
|
||||
readonly targets: string[]
|
||||
readonly start: string
|
||||
readonly stop?: string
|
||||
readonly mode?: "all" | "first"
|
||||
}
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
@@ -40,7 +47,7 @@ export namespace FSUtil {
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly resolve: (path: string) => Effect.Effect<string>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: UpOptions) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
@@ -153,27 +160,16 @@ export namespace FSUtil {
|
||||
})
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: UpOptions) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (yield* fs.exists(search)) {
|
||||
result.push(search)
|
||||
if (options.mode === "first") return result
|
||||
}
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
@@ -183,6 +179,10 @@ export namespace FSUtil {
|
||||
return result
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")((target: string, start: string, stop?: string) =>
|
||||
up({ targets: [target], start, stop }),
|
||||
)
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
|
||||
@@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.
|
||||
index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644
|
||||
--- a/dist/cjs/client/streamableHttp.js
|
||||
+++ b/dist/cjs/client/streamableHttp.js
|
||||
@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport {
|
||||
if (!event.event || event.event === 'message') {
|
||||
try {
|
||||
const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
- if ((0, types_js_1.isJSONRPCResultResponse)(message)) {
|
||||
+ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) {
|
||||
// Mark that we received a response - no need to reconnect for this request
|
||||
receivedResponse = true;
|
||||
if (replayMessageId !== undefined) {
|
||||
@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
@@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
|
||||
-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
+import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport {
|
||||
if (!event.event || event.event === 'message') {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
- if (isJSONRPCResultResponse(message)) {
|
||||
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
|
||||
// Mark that we received a response - no need to reconnect for this request
|
||||
receivedResponse = true;
|
||||
if (replayMessageId !== undefined) {
|
||||
@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user