mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d729521d3 | |||
| c391983ae0 | |||
| f158abd694 | |||
| 0e3da4b4f1 | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f | |||
| 85ea15e56d |
@@ -4,6 +4,13 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
@@ -33,7 +32,6 @@ const patterns = [
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
@@ -44,11 +42,15 @@ const patterns = [
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
|
||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
||||
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
expect(failures).toEqual(
|
||||
failures.map((failure) =>
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"ProviderInternal",
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -45,7 +45,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
|
||||
},
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
|
||||
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
|
||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
return providerError("provider.rate-limit", cause.reason)
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
return providerError("provider.auth", cause.reason)
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
return providerError("provider.quota", cause.reason)
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
return providerError("provider.content-filter", cause.reason)
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
return providerError("provider.transport", cause.reason)
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
return providerError("provider.internal", cause.reason)
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
return providerError("provider.invalid-output", cause.reason)
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
return providerError("provider.invalid-request", cause.reason)
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
return providerError("provider.no-route", cause.reason)
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
return providerError("provider.unknown", cause.reason)
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
const http = new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||
})
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
})
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||
type: "provider.internal",
|
||||
message: "bad gateway",
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||
export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
+20
-11
@@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { sessionTabsFitVertically } from "./ui/layout"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
@@ -83,6 +84,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -208,9 +210,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
|
||||
)
|
||||
const directory = location.directory
|
||||
const pluginDirectories = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config),
|
||||
)
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const managed = input.server.service
|
||||
@@ -378,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
@@ -519,6 +528,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
||||
const tabsVisible = () =>
|
||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
@@ -1214,16 +1226,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<Show when={tabsVisible() && tabsVertical()}>
|
||||
<SessionTabs orientation="vertical" />
|
||||
</Show>
|
||||
<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 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<Show when={tabsVisible() && !tabsVertical()}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -100,6 +100,15 @@ export const settings: Setting[] = [
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
{
|
||||
title: "Vertical",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "vertical"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => {
|
||||
const provider = providers().get(model.providerID)
|
||||
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
return {
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
providerID: model.providerID,
|
||||
providerName: provider?.name ?? model.providerID,
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
favorite,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
if (needle) {
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
return prioritizeFavorites(
|
||||
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||
)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||
}
|
||||
|
||||
export function sortModelOptions<
|
||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||
>(options: T[]) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -18,6 +17,7 @@ import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
|
||||
@@ -81,7 +81,7 @@ export function DialogOpen() {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const name = projectName(project)
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
@@ -109,7 +109,7 @@ export function DialogOpen() {
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -123,8 +124,7 @@ export function DialogSessionList() {
|
||||
const current = data.location.info()
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
if (!project) return ""
|
||||
return project.name || path.basename(project.canonical)
|
||||
return projectName(project) ?? ""
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -141,9 +141,7 @@ export function DialogSessionList() {
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: undefined
|
||||
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
|
||||
@@ -23,6 +23,7 @@ import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
@@ -501,22 +502,19 @@ export function Autocomplete(props: {
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
moveTo(next)
|
||||
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const viewportHeight = Math.min(height(), options().length)
|
||||
const scrollBottom = scroll.scrollTop + viewportHeight
|
||||
if (next < scroll.scrollTop) {
|
||||
scroll.scrollBy(next - scroll.scrollTop)
|
||||
} else if (next + 1 > scrollBottom) {
|
||||
scroll.scrollBy(next + 1 - scrollBottom)
|
||||
}
|
||||
const offset = revealSelectionOffset(scroll.scrollTop, {
|
||||
count: options().length,
|
||||
limit: Math.min(height(), options().length),
|
||||
selected: next,
|
||||
})
|
||||
if (offset === scroll.scrollTop) return
|
||||
scroll.scrollBy(offset - scroll.scrollTop)
|
||||
}
|
||||
|
||||
function select() {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
@@ -55,8 +54,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useData } from "../context/data"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
@@ -19,6 +20,8 @@ import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -39,8 +42,338 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
|
||||
}
|
||||
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
if (props.orientation === "vertical")
|
||||
return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
}
|
||||
|
||||
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const width = () => SESSION_SIDEBAR_WIDTH
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
items().map((tab) => {
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
runs: status.busy && !status.attention,
|
||||
glows:
|
||||
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!scroll) return
|
||||
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
||||
if (index === -1) return
|
||||
const top = index * 3
|
||||
if (top < scroll.scrollTop) return scroll.scrollTo(top)
|
||||
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
|
||||
scroll.scrollTo(top + 2 - scroll.viewport.height)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
width={width()}
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
<For each={items()}>
|
||||
{(tab, index) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = createMemo(() => itemStatus(tab))
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => String(index() + 1).length + 1
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
})
|
||||
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), Number(selected()))
|
||||
const color = tint(base, accent(), Number(complete()))
|
||||
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
|
||||
}
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
const glowHue = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
const tab = previous()
|
||||
return tab
|
||||
? itemStatus(tab)
|
||||
: { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
|
||||
})
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const runs = () => status().runs
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return theme.text.feedback.warning.default
|
||||
if (previousStatus().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const titleColor = (index: number) => {
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
top={-1}
|
||||
edge="above"
|
||||
enabled={animations()}
|
||||
active={runs()}
|
||||
outerActive={previousRuns()}
|
||||
promptPulse={status().promptPulse}
|
||||
outerPromptPulse={previousStatus().promptPulse}
|
||||
complete={complete() && !status().attention}
|
||||
outerComplete={previousStatus().complete && !previousStatus().attention}
|
||||
glow={glows()}
|
||||
outerGlow={previousGlows()}
|
||||
breathe={status().attention}
|
||||
outerBreathe={previousStatus().attention}
|
||||
color={separatorLowerPulseColor()}
|
||||
outerColor={separatorUpperPulseColor()}
|
||||
glowColor={separatorLowerColor()}
|
||||
outerGlowColor={separatorUpperColor()}
|
||||
glowTail={8}
|
||||
outerGlowTail={5}
|
||||
completionColor={separatorLowerColor()}
|
||||
outerCompletionColor={separatorUpperColor()}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
<Show when={index() === items().length - 1}>
|
||||
<TabPulse
|
||||
top={2}
|
||||
edge="below"
|
||||
enabled={animations()}
|
||||
active={runs()}
|
||||
outerActive={false}
|
||||
promptPulse={status().promptPulse}
|
||||
outerPromptPulse={0}
|
||||
complete={complete() && !status().attention}
|
||||
outerComplete={false}
|
||||
glow={glows()}
|
||||
outerGlow={false}
|
||||
breathe={status().attention}
|
||||
outerBreathe={false}
|
||||
color={tint(theme.background.default, theme.text.default, 0.04)}
|
||||
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
outerGlowColor={theme.background.default}
|
||||
glowTail={8}
|
||||
outerGlowTail={5}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
outerCompletionColor={theme.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
</Show>
|
||||
<box height={1} width="100%" flexDirection="row" position="relative">
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={runs()}
|
||||
promptPulse={status().promptPulse}
|
||||
complete={complete() && !status().attention}
|
||||
glow={glows()}
|
||||
breathe={status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={glowColor()}
|
||||
completionColor={glowColor()}
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{index() + 1}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box height={1} width="100%" position="relative" flexDirection="row">
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={runs()}
|
||||
promptPulse={status().promptPulse}
|
||||
complete={complete() && !status().attention}
|
||||
glow={glows()}
|
||||
breathe={status().attention}
|
||||
color={detailPulseColor()}
|
||||
glowColor={detailGlowColor()}
|
||||
glowTail={10}
|
||||
completionColor={detailGlowColor()}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
@@ -269,9 +602,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows()
|
||||
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
|
||||
: base
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
|
||||
const position = index - (displayedParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
|
||||
@@ -2,16 +2,28 @@ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderC
|
||||
import { extend } from "@opentui/solid"
|
||||
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
edge?: "above" | "below"
|
||||
enabled?: boolean
|
||||
active?: boolean
|
||||
outerActive?: boolean
|
||||
promptPulse?: number
|
||||
outerPromptPulse?: number
|
||||
complete?: boolean
|
||||
outerComplete?: boolean
|
||||
glow?: boolean
|
||||
outerGlow?: boolean
|
||||
breathe?: boolean
|
||||
outerBreathe?: boolean
|
||||
color?: RGBA
|
||||
outerColor?: RGBA
|
||||
glowColor?: RGBA
|
||||
outerGlowColor?: RGBA
|
||||
glowTail?: number
|
||||
outerGlowTail?: number
|
||||
flashColor?: RGBA
|
||||
outerFlashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
outerCompletionColor?: RGBA
|
||||
backgroundColor?: RGBA
|
||||
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
|
||||
onLevel?: (level: number) => void
|
||||
@@ -20,6 +32,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_ATTACK = 450
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
@@ -58,9 +71,10 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
|
||||
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
|
||||
export const glowIgnitionLevel = (progress: number) =>
|
||||
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
|
||||
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
|
||||
const tail = Math.min(maximumTail, Math.max(1, width - 2))
|
||||
return glowIntensityAt(index, tail)
|
||||
}
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
@@ -131,45 +145,236 @@ class Envelope {
|
||||
// Hoisted so the per-frame liveness check allocates no closure.
|
||||
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
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _flashColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
type PulseStateOptions = {
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
promptPulse: number
|
||||
complete: boolean
|
||||
glow: boolean
|
||||
breathe: boolean
|
||||
}
|
||||
|
||||
class PulseState {
|
||||
private enabled: boolean
|
||||
private active: boolean
|
||||
private promptPulse: number
|
||||
private complete: boolean
|
||||
private glow: boolean
|
||||
private breathe: boolean
|
||||
private clock = 0
|
||||
private breatheClock = 0
|
||||
private completionPending = false
|
||||
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
this.active = options.active
|
||||
this.promptPulse = options.promptPulse
|
||||
this.complete = options.complete
|
||||
this.glow = options.glow
|
||||
this.breathe = options.breathe
|
||||
if (this.enabled && this.active) this.runAttack.start()
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this.enabled && this.glow && this.breathe
|
||||
}
|
||||
|
||||
get live() {
|
||||
return this.active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
}
|
||||
|
||||
get running() {
|
||||
if (!this.enabled) return 0
|
||||
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
|
||||
}
|
||||
|
||||
get completion() {
|
||||
return this.completionPulse.level() * COMPLETION_OPACITY
|
||||
}
|
||||
|
||||
get flash() {
|
||||
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
}
|
||||
|
||||
get glowLevel() {
|
||||
if (!this.glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
} else if (this.active) {
|
||||
this.runAttack.restart()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setActive(value: boolean) {
|
||||
if (value === this.active) return false
|
||||
this.active = value
|
||||
if (!this.enabled) return true
|
||||
if (value) {
|
||||
this.clock = 0
|
||||
this.runAttack.restart()
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
const level = this.runAttack.active ? this.runAttack.level() : 1
|
||||
this.runAttack.stop()
|
||||
this.runFade.start(level)
|
||||
this.completionPending = true
|
||||
}
|
||||
this.edgeFlash.start()
|
||||
return true
|
||||
}
|
||||
|
||||
setPromptPulse(value: number) {
|
||||
if (value === this.promptPulse) return false
|
||||
this.promptPulse = value
|
||||
if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
return true
|
||||
}
|
||||
|
||||
setComplete(value: boolean) {
|
||||
if (value === this.complete) return false
|
||||
this.complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this.enabled) this.completionPulse.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setBreathe(value: boolean) {
|
||||
if (value === this.breathe) return false
|
||||
this.breathe = value
|
||||
this.breatheClock = 0
|
||||
return true
|
||||
}
|
||||
|
||||
advance(deltaTime: number) {
|
||||
if (!this.enabled) return
|
||||
if (this.active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (!this.completionPending) return
|
||||
if (this.complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
return
|
||||
}
|
||||
if (!this.runFade.active) this.completionPending = false
|
||||
}
|
||||
|
||||
fronts(width: number) {
|
||||
const cycles = this.clock / RUN_DURATION
|
||||
const progress = cycles % 1
|
||||
const start = -RUN_HEAD
|
||||
const end = width - 1 + RUN_TAIL
|
||||
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
|
||||
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
|
||||
}
|
||||
}
|
||||
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private inner: PulseState
|
||||
private outer: PulseState
|
||||
private _color: RGBA
|
||||
private _outerColor: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _outerGlowColor: RGBA
|
||||
private _glowTail: number
|
||||
private _outerGlowTail: number
|
||||
private _edge: "above" | "below" | undefined
|
||||
private _flashColor: RGBA
|
||||
private _outerFlashColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _outerCompletionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
private renderColor = RGBA.fromInts(0, 0, 0)
|
||||
private outerRenderColor = RGBA.fromInts(0, 0, 0)
|
||||
private _onLevel: ((level: number) => void) | undefined
|
||||
private lastLevel = 0
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
const active = options.active ?? false
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
const glow = options.glow ?? false
|
||||
const breathe = options.breathe ?? false
|
||||
const edge = options.edge
|
||||
const outerActive = options.outerActive ?? active
|
||||
const outerGlow = options.outerGlow ?? glow
|
||||
const outerBreathe = options.outerBreathe ?? breathe
|
||||
super(ctx, {
|
||||
...options,
|
||||
height: 1,
|
||||
live:
|
||||
enabled &&
|
||||
(active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
|
||||
})
|
||||
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
|
||||
this.inner = new PulseState({
|
||||
enabled,
|
||||
active,
|
||||
promptPulse: options.promptPulse ?? 0,
|
||||
complete: options.complete ?? false,
|
||||
glow,
|
||||
breathe,
|
||||
})
|
||||
this.outer = new PulseState({
|
||||
enabled: enabled && edge !== undefined,
|
||||
active: outerActive,
|
||||
promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
|
||||
complete: options.outerComplete ?? options.complete ?? false,
|
||||
glow: outerGlow,
|
||||
breathe: outerBreathe,
|
||||
})
|
||||
this._color = options.color ?? RGBA.defaultForeground()
|
||||
this._outerColor = options.outerColor ?? this._color
|
||||
this._glowColor = options.glowColor ?? this._color
|
||||
this._outerGlowColor = options.outerGlowColor ?? this._glowColor
|
||||
this._glowTail = options.glowTail ?? GLOW_TAIL
|
||||
this._outerGlowTail = options.outerGlowTail ?? this._glowTail
|
||||
this._edge = edge
|
||||
this._flashColor = options.flashColor ?? this._color
|
||||
this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor
|
||||
this._completionColor = options.completionColor ?? this._color
|
||||
this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
|
||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||
this._onLevel = options.onLevel
|
||||
}
|
||||
@@ -179,103 +384,64 @@ class TabPulseRenderable extends Renderable {
|
||||
}
|
||||
|
||||
private emitLevel(value: number) {
|
||||
if (!this._onLevel) return
|
||||
const quantized = Math.round(value * 32) / 32
|
||||
if (quantized === this.lastLevel) return
|
||||
this.lastLevel = quantized
|
||||
this._onLevel?.(quantized)
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this._enabled && this._glow && this._breathe
|
||||
}
|
||||
|
||||
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
|
||||
private glowLevel() {
|
||||
if (!this._glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
this.live = false
|
||||
} else if (this._active || this.breathing) {
|
||||
this.live = true
|
||||
}
|
||||
this.inner.setEnabled(value)
|
||||
this.outer.setEnabled(value && this._edge !== undefined)
|
||||
this.live = this.inner.live || this.outer.live
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set active(value: boolean) {
|
||||
if (value === this._active) return
|
||||
this._active = value
|
||||
if (!this._enabled) return
|
||||
if (value) {
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
this.runFade.start()
|
||||
this.completionPending = true
|
||||
}
|
||||
// The same neutral edge flash marks both the start and the finish of a run.
|
||||
this.edgeFlash.start()
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
if (this.inner.setActive(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerActive(value: boolean) {
|
||||
if (this.outer.setActive(value)) this.changed()
|
||||
}
|
||||
|
||||
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()
|
||||
if (this.inner.setPromptPulse(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerPromptPulse(value: number) {
|
||||
if (this.outer.setPromptPulse(value)) this.changed()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this._enabled) {
|
||||
this.completionPulse.start()
|
||||
this.live = true
|
||||
}
|
||||
}
|
||||
this.requestRender()
|
||||
if (this.inner.setComplete(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerComplete(value: boolean) {
|
||||
if (this.outer.setComplete(value)) this.changed()
|
||||
}
|
||||
|
||||
set glow(value: boolean) {
|
||||
if (value === this._glow) return
|
||||
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
|
||||
this._glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this._enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
if (this.inner.setGlow(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerGlow(value: boolean) {
|
||||
if (this.outer.setGlow(value)) this.changed()
|
||||
}
|
||||
|
||||
set breathe(value: boolean) {
|
||||
if (value === this._breathe) return
|
||||
this._breathe = value
|
||||
this.breatheClock = 0
|
||||
if (this.breathing) this.live = true
|
||||
if (this.inner.setBreathe(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerBreathe(value: boolean) {
|
||||
if (this.outer.setBreathe(value)) this.changed()
|
||||
}
|
||||
|
||||
private changed() {
|
||||
this.live = this.inner.live || this.outer.live
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
@@ -291,18 +457,62 @@ class TabPulseRenderable extends Renderable {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set outerColor(value: RGBA) {
|
||||
if (value.equals(this._outerColor)) return
|
||||
this._outerColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set outerGlowColor(value: RGBA) {
|
||||
if (value.equals(this._outerGlowColor)) return
|
||||
this._outerGlowColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glowTail(value: number) {
|
||||
if (value === this._glowTail) return
|
||||
this._glowTail = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set outerGlowTail(value: number) {
|
||||
if (value === this._outerGlowTail) return
|
||||
this._outerGlowTail = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set edge(value: "above" | "below" | undefined) {
|
||||
if (value === this._edge) return
|
||||
this._edge = value
|
||||
this.outer.setEnabled(this._enabled && value !== undefined)
|
||||
this.live = this.inner.live || this.outer.live
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set flashColor(value: RGBA) {
|
||||
if (value.equals(this._flashColor)) return
|
||||
this._flashColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set outerFlashColor(value: RGBA) {
|
||||
if (value.equals(this._outerFlashColor)) return
|
||||
this._outerFlashColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set completionColor(value: RGBA) {
|
||||
if (value.equals(this._completionColor)) return
|
||||
this._completionColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set outerCompletionColor(value: RGBA) {
|
||||
if (value.equals(this._outerCompletionColor)) return
|
||||
this._outerCompletionColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set backgroundColor(value: RGBA) {
|
||||
if (value.equals(this._backgroundColor)) return
|
||||
this._backgroundColor = value
|
||||
@@ -311,42 +521,45 @@ class TabPulseRenderable extends Renderable {
|
||||
|
||||
protected override onUpdate(deltaTime: number): void {
|
||||
if (!this._enabled) return
|
||||
if (this._active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (this.completionPending) {
|
||||
if (this._complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
} else if (!this.runFade.active) {
|
||||
this.completionPending = false
|
||||
}
|
||||
}
|
||||
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
this.inner.advance(deltaTime)
|
||||
this.outer.advance(deltaTime)
|
||||
this.live = this.inner.live || this.outer.live
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
|
||||
const completion = this.completionPulse.level() * COMPLETION_OPACITY
|
||||
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
|
||||
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
const glowLevel = this.glowLevel()
|
||||
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
|
||||
const running = this.inner.running
|
||||
const completion = this.inner.completion
|
||||
const flash = this.inner.flash
|
||||
const glowLevel = this.inner.glowLevel
|
||||
const outerRunning = this.outer.running
|
||||
const outerCompletion = this.outer.completion
|
||||
const outerFlash = this.outer.flash
|
||||
const outerGlowLevel = this.outer.glowLevel
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
outerGlowLevel === 0 &&
|
||||
outerRunning === 0 &&
|
||||
outerCompletion === 0 &&
|
||||
outerFlash === 0
|
||||
) {
|
||||
this.emitLevel(0)
|
||||
return
|
||||
}
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
const [front, secondFront] = this.inner.fronts(this.width)
|
||||
const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
|
||||
if (this._onLevel)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
||||
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
|
||||
for (let index = 0; index < this.width; index++) {
|
||||
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
|
||||
const sweep =
|
||||
@@ -358,6 +571,15 @@ class TabPulseRenderable extends Renderable {
|
||||
) *
|
||||
0.14 *
|
||||
running
|
||||
const outerSweep =
|
||||
outerRunning === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
|
||||
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
|
||||
) *
|
||||
0.14 *
|
||||
outerRunning
|
||||
blendTabPulseColor(
|
||||
this.renderColor,
|
||||
this._backgroundColor,
|
||||
@@ -365,12 +587,34 @@ class TabPulseRenderable extends Renderable {
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
sweep,
|
||||
flash,
|
||||
completion,
|
||||
)
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
if (!this._edge) {
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
continue
|
||||
}
|
||||
blendTabPulseColor(
|
||||
this.outerRenderColor,
|
||||
this._backgroundColor,
|
||||
this._outerGlowColor,
|
||||
this._outerColor,
|
||||
this._outerFlashColor,
|
||||
this._outerCompletionColor,
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerSweep,
|
||||
outerFlash,
|
||||
outerCompletion,
|
||||
)
|
||||
buffer.setCell(
|
||||
this.screenX + index,
|
||||
this.screenY,
|
||||
this._edge === "above" ? "▄" : "▀",
|
||||
this.renderColor,
|
||||
this.outerRenderColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,34 +628,61 @@ declare module "@opentui/solid" {
|
||||
extend({ tab_pulse: TabPulseRenderable })
|
||||
|
||||
export function TabPulse(props: {
|
||||
top?: number
|
||||
width?: number
|
||||
edge?: "above" | "below"
|
||||
enabled?: boolean
|
||||
active: boolean
|
||||
outerActive?: boolean
|
||||
promptPulse?: number
|
||||
outerPromptPulse?: number
|
||||
complete?: boolean
|
||||
outerComplete?: boolean
|
||||
glow?: boolean
|
||||
outerGlow?: boolean
|
||||
breathe?: boolean
|
||||
outerBreathe?: boolean
|
||||
color: RGBA
|
||||
outerColor?: RGBA
|
||||
glowColor?: RGBA
|
||||
outerGlowColor?: RGBA
|
||||
glowTail?: number
|
||||
outerGlowTail?: number
|
||||
flashColor?: RGBA
|
||||
outerFlashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
outerCompletionColor?: RGBA
|
||||
backgroundColor: RGBA
|
||||
onLevel?: (level: number) => void
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
position="absolute"
|
||||
top={props.top}
|
||||
edge={props.edge}
|
||||
zIndex={0}
|
||||
width="100%"
|
||||
width={props.width ?? "100%"}
|
||||
enabled={props.enabled ?? true}
|
||||
active={props.active}
|
||||
outerActive={props.outerActive ?? props.active}
|
||||
promptPulse={props.promptPulse ?? 0}
|
||||
outerPromptPulse={props.outerPromptPulse ?? props.promptPulse ?? 0}
|
||||
complete={props.complete ?? false}
|
||||
outerComplete={props.outerComplete ?? props.complete ?? false}
|
||||
glow={props.glow ?? false}
|
||||
outerGlow={props.outerGlow ?? props.glow ?? false}
|
||||
breathe={props.breathe ?? false}
|
||||
outerBreathe={props.outerBreathe ?? props.breathe ?? false}
|
||||
color={props.color}
|
||||
outerColor={props.outerColor ?? props.color}
|
||||
glowColor={props.glowColor ?? props.color}
|
||||
outerGlowColor={props.outerGlowColor ?? props.glowColor ?? props.color}
|
||||
glowTail={props.glowTail ?? GLOW_TAIL}
|
||||
outerGlowTail={props.outerGlowTail ?? props.glowTail ?? GLOW_TAIL}
|
||||
flashColor={props.flashColor ?? props.color}
|
||||
outerFlashColor={props.outerFlashColor ?? props.flashColor ?? props.outerColor ?? props.color}
|
||||
completionColor={props.completionColor ?? props.color}
|
||||
outerCompletionColor={props.outerCompletionColor ?? props.completionColor ?? props.outerColor ?? props.color}
|
||||
backgroundColor={props.backgroundColor}
|
||||
onLevel={props.onLevel}
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
vertical: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
|
||||
@@ -67,8 +67,6 @@ function initialRoute(value: unknown): Route | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export type RouteContext = ReturnType<typeof useRoute>
|
||||
|
||||
export function useRouteData<T extends Route["type"]>(type: T) {
|
||||
const route = useRoute()
|
||||
return route.data as Extract<Route, { type: typeof type }>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
@@ -30,6 +30,7 @@ import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||
export type ThemeError = { name: string; error: Error }
|
||||
@@ -70,7 +71,7 @@ export type ThemeSource = Readonly<{
|
||||
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
|
||||
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
@@ -10,24 +10,18 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
|
||||
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const activeSubagents = createMemo(() => {
|
||||
const subagents = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return props.context.data.session
|
||||
const count = props.context.data.session
|
||||
.family(props.sessionID)
|
||||
.filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length
|
||||
})
|
||||
const runningShells = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return props.context.data.shell
|
||||
.list(props.context.location)
|
||||
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
|
||||
})
|
||||
const subagents = createMemo(() => {
|
||||
const count = activeSubagents()
|
||||
return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined
|
||||
})
|
||||
const shells = createMemo(() => {
|
||||
const count = runningShells()
|
||||
if (!props.sessionID) return 0
|
||||
const count = props.context.data.shell
|
||||
.list(props.context.location)
|
||||
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
|
||||
return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
@@ -40,8 +34,10 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
session.revert?.messageID,
|
||||
)
|
||||
const cost = props.context.data.session.cost(props.sessionID)
|
||||
return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
return [
|
||||
usage ? formatContextUsage(usage.tokens, usage.percent) : undefined,
|
||||
cost > 0 ? money.format(cost) : undefined,
|
||||
].filter((item): item is string => Boolean(item))
|
||||
})
|
||||
const live = createMemo(() => Boolean(subagents() || shells()))
|
||||
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
|
||||
|
||||
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const theme = props.context.theme
|
||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
@@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
<Show when={state() || cost() > 0}>
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={cost() > 0}>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -90,10 +90,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
draining = (async () => {
|
||||
try {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const prompt = state.queue.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
const prompt = state.queue.shift()!
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
if (!input.onNewSession) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
@@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
@@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
void enqueue(reconcile).catch(() => undefined)
|
||||
}, 100)
|
||||
})
|
||||
onCleanup(() => {
|
||||
const stopWatching = () => {
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
})
|
||||
}
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
// core plugin registry: fold the ordered entries into a desired end state
|
||||
@@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
// every watch event; remember them until the configuration changes.
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
watcher.add(tuiPluginDirectory(host.paths.cwd))
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const options = typeof entry === "string" ? undefined : entry.options
|
||||
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
|
||||
const local = localSource(target, directory)
|
||||
if (local) watcher.add(fileURLToPath(local))
|
||||
if (local) await watcher.add(fileURLToPath(local))
|
||||
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
@@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
if (disposing) return disposing
|
||||
stopWatching()
|
||||
disposing = loading
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import {
|
||||
isMissingPath,
|
||||
localProjectDirectory,
|
||||
projectConfigDirectories,
|
||||
} from "../util/config-directories"
|
||||
|
||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
||||
|
||||
export function tuiPluginDirectory(cwd: string) {
|
||||
return path.join(cwd, ".opencode", "plugins", "tui")
|
||||
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
|
||||
const projectDirectory = await localProjectDirectory(cwd)
|
||||
const projectConfig = path.join(projectDirectory, ".opencode")
|
||||
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
|
||||
const exists = await Promise.all(
|
||||
directories.map((directory) => {
|
||||
if (directory === configDirectory || directory === projectConfig) return true
|
||||
return stat(directory).then(
|
||||
(info) => info.isDirectory(),
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return directories
|
||||
.filter((_, index) => exists[index])
|
||||
.map((directory) => path.join(directory, "plugins", "tui"))
|
||||
}
|
||||
|
||||
export async function discoverTuiPlugins(cwd: string) {
|
||||
const directory = tuiPluginDirectory(cwd)
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
export async function discoverTuiPlugins(directories: string[]) {
|
||||
return (
|
||||
await Promise.all(
|
||||
directories.map(async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (isMissingPath(error)) return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
|
||||
@@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Failed or vanished watches are
|
||||
// forgotten so a later add() can re-arm once the path exists.
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
export function createSourceWatcher(onChange: () => void) {
|
||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||
const watched = new Map<string, Set<string> | null>()
|
||||
const missing = new Set<string>()
|
||||
const arming = new Map<string, Promise<void>>()
|
||||
let disposed = false
|
||||
const notify = () => {
|
||||
if (!disposed) onChange()
|
||||
}
|
||||
const forget = (dir: string) => {
|
||||
watchers.get(dir)?.close()
|
||||
watchers.delete(dir)
|
||||
watched.delete(dir)
|
||||
}
|
||||
const arm = (target: string) => {
|
||||
stat(target)
|
||||
const arm = (target: string, retry: boolean) => {
|
||||
const active = arming.get(target)
|
||||
if (active) return active
|
||||
const result = stat(target)
|
||||
.then((info) => {
|
||||
if (disposed) return
|
||||
const appeared = missing.delete(target)
|
||||
const dir = info.isDirectory() ? target : path.dirname(target)
|
||||
// Directories accept every filename (null); files accept their basename.
|
||||
const name = info.isDirectory() ? null : path.basename(target)
|
||||
@@ -32,44 +41,69 @@ export function createSourceWatcher(onChange: () => void) {
|
||||
if (existing !== undefined) {
|
||||
if (name === null) watched.set(dir, null)
|
||||
else existing?.add(name)
|
||||
if (appeared) notify()
|
||||
return
|
||||
}
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
const watcher = watch(dir, (_event, filename) => {
|
||||
// A replaced directory keeps this watcher on the dead inode (Linux
|
||||
// emits rename, not error); forget it so a later add() re-arms on
|
||||
// the recreated path, and still schedule so reconcile runs now.
|
||||
if (!existsSync(dir)) {
|
||||
forget(dir)
|
||||
onChange()
|
||||
notify()
|
||||
return
|
||||
}
|
||||
// A null filename (platform-dependent) always schedules.
|
||||
const accept = watched.get(dir)
|
||||
if (filename && accept && !accept.has(filename.toString())) return
|
||||
onChange()
|
||||
notify()
|
||||
})
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
// Reconcile after watcher errors so every source is re-added and any
|
||||
// temporarily unavailable target moves into the polling set.
|
||||
watcher.on("error", () => {
|
||||
forget(dir)
|
||||
notify()
|
||||
})
|
||||
// A watched directory can disappear out from under us; without a
|
||||
// listener the error event would crash the process. Forget the path
|
||||
// so a later add can re-arm once it exists again.
|
||||
watcher.on("error", () => forget(dir))
|
||||
watchers.set(dir, watcher)
|
||||
if (appeared) notify()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (!disposed && retry && isMissing(error)) missing.add(target)
|
||||
})
|
||||
.finally(() => arming.delete(target))
|
||||
arming.set(target, result)
|
||||
return result
|
||||
}
|
||||
const add = (target: string) => {
|
||||
arm(target)
|
||||
const add = async (target: string, retry: boolean) => {
|
||||
await arm(target, retry)
|
||||
// A symlinked source receives edits at its resolved target.
|
||||
lstat(target)
|
||||
await lstat(target)
|
||||
.then((info) => {
|
||||
if (!info.isSymbolicLink()) return
|
||||
return realpath(target).then(arm)
|
||||
return realpath(target).then((target) => arm(target, retry))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
clearInterval(poll)
|
||||
for (const watcher of watchers.values()) watcher.close()
|
||||
watchers.clear()
|
||||
watched.clear()
|
||||
missing.clear()
|
||||
}
|
||||
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
|
||||
poll.unref()
|
||||
return {
|
||||
add: (target: string) => add(target, false),
|
||||
wait: (target: string) => add(target, true),
|
||||
dispose,
|
||||
}
|
||||
return { add, dispose }
|
||||
}
|
||||
|
||||
function isMissing(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const code = Reflect.get(error, "code")
|
||||
return code === "ENOENT" || code === "ENOTDIR"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { errorMessage } from "../../util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
@@ -199,14 +200,21 @@ export function Session() {
|
||||
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
|
||||
const groupExploration = createMemo(() => config.session?.grouping !== "none")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const availableWidth = createMemo(
|
||||
() =>
|
||||
dimensions().width -
|
||||
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
||||
? SESSION_SIDEBAR_WIDTH
|
||||
: 0),
|
||||
)
|
||||
const wide = createMemo(() => availableWidth() > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const models = createMemo(() => data.location.model.list(location()) ?? [])
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PluginSlot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const data = useData()
|
||||
@@ -18,7 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
width={42}
|
||||
width={SESSION_SIDEBAR_WIDTH}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -27,9 +28,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(scroll) =>
|
||||
queueMicrotask(() => {
|
||||
if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl()
|
||||
})
|
||||
}
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
visible: false,
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export function themeDirectories(config: string, cwd: string) {
|
||||
const directories: string[] = []
|
||||
for (let current = cwd; ; current = path.dirname(current)) {
|
||||
directories.push(path.join(current, ".opencode"))
|
||||
if (path.dirname(current) === current) break
|
||||
}
|
||||
return [config, ...directories.reverse()]
|
||||
}
|
||||
|
||||
export async function discoverThemes(directories: string[]) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const directory of directories) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { useDialog } from "./dialog"
|
||||
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Spinner } from "../component/spinner"
|
||||
|
||||
@@ -112,14 +112,3 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SESSION_SIDEBAR_WIDTH = 42
|
||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||
|
||||
export function sessionTabsFitVertically(total: number) {
|
||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import path from "node:path"
|
||||
import { stat } from "node:fs/promises"
|
||||
|
||||
export function configDirectories(config: string, cwd: string) {
|
||||
return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])]
|
||||
}
|
||||
|
||||
export function projectConfigDirectories(project: string, cwd: string) {
|
||||
const directories = ancestors(cwd)
|
||||
return directories
|
||||
.slice(directories.indexOf(path.resolve(project)))
|
||||
.map((directory) => path.join(directory, ".opencode"))
|
||||
}
|
||||
|
||||
export async function localProjectDirectory(cwd: string) {
|
||||
const directories = ancestors(cwd)
|
||||
const repositories = await Promise.all(
|
||||
directories.map((directory) =>
|
||||
Promise.all(
|
||||
[".git", ".hg"].map((name) =>
|
||||
stat(path.join(directory, name)).then(
|
||||
() => true,
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
),
|
||||
),
|
||||
).then((matches) => matches.some(Boolean)),
|
||||
),
|
||||
)
|
||||
return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd)
|
||||
}
|
||||
|
||||
export function isMissingPath(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const code = Reflect.get(error, "code")
|
||||
return code === "ENOENT" || code === "ENOTDIR"
|
||||
}
|
||||
|
||||
function ancestors(cwd: string) {
|
||||
const directories: string[] = []
|
||||
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
|
||||
directories.push(current)
|
||||
if (path.dirname(current) === current) break
|
||||
}
|
||||
return directories.reverse()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import path from "path"
|
||||
|
||||
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
|
||||
const canonical = project?.canonical ?? fallback
|
||||
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
|
||||
return project?.name || path.basename(canonical)
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("prioritizeFavorites", () => {
|
||||
test("moves favorites first while preserving fuzzy result order", () => {
|
||||
const prioritized = prioritizeFavorites([
|
||||
{ title: "Best match", favorite: false },
|
||||
{ title: "Favorite match", favorite: true },
|
||||
{ title: "Second best match", favorite: false },
|
||||
{ title: "Second favorite match", favorite: true },
|
||||
])
|
||||
|
||||
expect(prioritized.map((model) => model.title)).toEqual([
|
||||
"Favorite match",
|
||||
"Second favorite match",
|
||||
"Best match",
|
||||
"Second best match",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortModelOptions", () => {
|
||||
test("orders opencode models before other providers", () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ test("validates mini replay settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
|
||||
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Context } from "@opencode-ai/plugin/tui/context"
|
||||
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
|
||||
|
||||
function context(options?: { cost?: number; tokens?: number }) {
|
||||
const color = RGBA.fromInts(200, 200, 200)
|
||||
return {
|
||||
theme: { text: { default: color, subdued: color } },
|
||||
data: {
|
||||
session: {
|
||||
get: () => ({ location: { directory: "/workspace" } }),
|
||||
cost: () => options?.cost ?? 0,
|
||||
message: {
|
||||
list: () =>
|
||||
options?.tokens
|
||||
? [
|
||||
{
|
||||
id: "message",
|
||||
type: "assistant",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
tokens: {
|
||||
input: options.tokens,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
location: {
|
||||
model: { list: () => [] },
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
test("sidebar omits context before usage is available", async () => {
|
||||
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
|
||||
width: 42,
|
||||
height: 8,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("Context")
|
||||
expect(app.captureCharFrame()).not.toContain("Not measured")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sidebar shows available context usage", async () => {
|
||||
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
|
||||
width: 42,
|
||||
height: 8,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Context")
|
||||
expect(app.captureCharFrame()).toContain("1,234 tokens")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -3,6 +3,10 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
|
||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
let ready!: () => void
|
||||
const promptReady = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
const events = input.events ?? []
|
||||
const commits = input.commits ?? []
|
||||
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
||||
@@ -14,6 +18,7 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
ready()
|
||||
return () => prompts.delete(fn)
|
||||
},
|
||||
onClose(fn) {
|
||||
@@ -50,9 +55,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
events,
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ describe("run interactive runtime", () => {
|
||||
const api = ui.api
|
||||
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
|
||||
const catalogLoaded = defer<void>()
|
||||
const defaultModelReloaded = defer<void>()
|
||||
const modelShown = defer<void>()
|
||||
const turnStarted = defer<void>()
|
||||
const model = catalogModel({
|
||||
id: "resolved",
|
||||
providerID: "test",
|
||||
@@ -69,7 +72,17 @@ describe("run interactive runtime", () => {
|
||||
providers: [catalogProvider("test", "Test Provider")],
|
||||
models: [model],
|
||||
})
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
|
||||
let defaultModelCalls = 0
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
|
||||
defaultModelCalls++
|
||||
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
|
||||
return selected.promise
|
||||
})
|
||||
const emit = api.event.bind(api)
|
||||
api.event = (event) => {
|
||||
emit(event)
|
||||
if (event.type === "model") modelShown.resolve()
|
||||
}
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
@@ -110,6 +123,7 @@ describe("run interactive runtime", () => {
|
||||
runPromptTurn: async (input) => {
|
||||
turnAgent = input.agent
|
||||
turnModel = input.model
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
@@ -133,8 +147,8 @@ describe("run interactive runtime", () => {
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
||||
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
|
||||
await defaultModelReloaded.promise
|
||||
await modelShown.promise
|
||||
expect(events).toContainEqual({
|
||||
type: "model",
|
||||
model: "Resolved Model · Test Provider",
|
||||
@@ -142,8 +156,9 @@ describe("run interactive runtime", () => {
|
||||
})
|
||||
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
|
||||
lifecycle.onAgentSelect?.("review")
|
||||
ui.submit("hello")
|
||||
while (!turnModel) await Bun.sleep(0)
|
||||
await ui.promptReady
|
||||
expect(ui.submit("hello")).toBe(true)
|
||||
await turnStarted.promise
|
||||
expect(turnAgent).toBe("review")
|
||||
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
|
||||
await task
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { discoverTuiPlugins } from "../src/plugin/discovery"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
|
||||
import { localProjectDirectory } from "../src/util/config-directories"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("discovers project TUI plugin files in stable order", async () => {
|
||||
@@ -15,13 +16,57 @@ test("discovers project TUI plugin files in stable order", async () => {
|
||||
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
|
||||
])
|
||||
|
||||
expect(await discoverTuiPlugins(tmp.path)).toEqual([
|
||||
path.join(directory, "first.js"),
|
||||
path.join(directory, "second.tsx"),
|
||||
])
|
||||
expect(
|
||||
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
|
||||
).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")])
|
||||
})
|
||||
|
||||
test("returns no project TUI plugins when the directory is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
|
||||
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
|
||||
expect(await discoverTuiPlugins(roots)).toEqual([])
|
||||
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
|
||||
})
|
||||
|
||||
test("discovers global and ancestor plugin roots in precedence order", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const config = path.join(tmp.path, "config")
|
||||
const directories = [
|
||||
path.join(config, "plugins", "tui"),
|
||||
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
|
||||
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
|
||||
]
|
||||
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(path.join(project, ".git"), { recursive: true })
|
||||
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
|
||||
await Promise.all(
|
||||
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
|
||||
)
|
||||
await writeFile(path.join(outside, "outside.ts"), "export default {}")
|
||||
|
||||
const roots = await tuiPluginDirectories(cwd, config)
|
||||
expect(await discoverTuiPlugins(roots)).toEqual(
|
||||
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
|
||||
)
|
||||
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
|
||||
expect(roots).not.toContain(outside)
|
||||
})
|
||||
|
||||
test("uses an Hg root for a missing project plugin directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const cwd = path.join(project, "package")
|
||||
await mkdir(path.join(project, ".hg"), { recursive: true })
|
||||
await mkdir(cwd, { recursive: true })
|
||||
|
||||
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
|
||||
path.join(project, ".opencode", "plugins", "tui"),
|
||||
)
|
||||
})
|
||||
|
||||
test("propagates non-missing filesystem errors", async () => {
|
||||
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
|
||||
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch } from "./fixture/tui-client"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
function lifecycleSource(marker: string, id: string, version: string) {
|
||||
@@ -36,7 +35,16 @@ async function bootApp(directory: string) {
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/fs/list") return
|
||||
return json({
|
||||
location: {
|
||||
directory,
|
||||
project: { id: "proj_test", directory, canonical: directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const cwd = process.cwd()
|
||||
process.chdir(directory)
|
||||
@@ -49,7 +57,10 @@ async function bootApp(directory: string) {
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(
|
||||
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
task,
|
||||
@@ -62,6 +73,34 @@ async function bootApp(directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("discovers an ancestor TUI plugin directory created after startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "repo", ".git"))
|
||||
const ready = path.join(tmp.path, "ready.txt")
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const initial = path.join(cwd, ".opencode", "plugins", "tui")
|
||||
await mkdir(initial, { recursive: true })
|
||||
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
|
||||
|
||||
await using app = await bootApp(cwd)
|
||||
expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
|
||||
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
|
||||
|
||||
expect(
|
||||
await until(
|
||||
() => readFile(marker, "utf8"),
|
||||
(value) => value === "v1:setup\n",
|
||||
),
|
||||
).toBe("v1:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
setCustomThemes,
|
||||
upsertTheme,
|
||||
} from "../src/theme"
|
||||
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
|
||||
import { discoverThemes } from "../src/theme/discovery"
|
||||
import { configDirectories } from "../src/util/config-directories"
|
||||
import { terminalMode } from "../src/theme/system"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -187,7 +188,7 @@ test("theme directories include global config before project directories", async
|
||||
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
|
||||
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
|
||||
|
||||
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
|
||||
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
|
||||
global: { source: "global" },
|
||||
project: { source: "project" },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
|
||||
|
||||
test("vertical tabs match the session sidebar and preserve compact content width", () => {
|
||||
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
|
||||
expect(sessionTabsFitVertically(86)).toBe(true)
|
||||
expect(sessionTabsFitVertically(85)).toBe(false)
|
||||
})
|
||||
Reference in New Issue
Block a user