mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4630e2922e | |||
| 39f7ca9152 | |||
| 41fe90c63b | |||
| d853ff8848 | |||
| 94bc0fc6fa | |||
| d3eecf7ba2 | |||
| 99166f7c17 | |||
| 08dcf7d731 |
@@ -157,6 +157,7 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
cost: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -595,6 +596,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
cost: usage.cost ?? undefined,
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
||||
reasoningTokens: Schema.optional(Schema.Number),
|
||||
totalTokens: Schema.optional(Schema.Number),
|
||||
/** Provider-reported cost for this physical request, normalized to USD. */
|
||||
cost: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
|
||||
@@ -246,6 +246,24 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports OpenRouter's streamed USD cost", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, cost: 0.00123 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage?.cost).toBe(0.00123)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails on a mid-stream provider error", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
|
||||
@@ -343,7 +343,8 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
model: (input) =>
|
||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
streamPrepared: (prepared) =>
|
||||
streamLanguage(language, prepared as LanguageModelV3CallOptions, info.providerID === Provider.ID.githubCopilot),
|
||||
}
|
||||
return LanguageModel.make({
|
||||
id: info.modelID ?? info.id,
|
||||
@@ -427,6 +428,7 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
|
||||
toolChoice: toolChoice(request.toolChoice),
|
||||
headers: request.http?.headers,
|
||||
providerOptions: providerOptions(request.providerOptions),
|
||||
includeRawChunks: request.model.provider === ProviderID.make(Provider.ID.githubCopilot),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,8 +549,15 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
}
|
||||
|
||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
||||
interface StreamState {
|
||||
step: number
|
||||
toolNames: Record<string, string>
|
||||
copilot: boolean
|
||||
cost?: number
|
||||
}
|
||||
|
||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, copilot: boolean) {
|
||||
const state: StreamState = { step: 0, toolNames: {}, copilot }
|
||||
return Stream.concat(
|
||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||
Stream.unwrap(
|
||||
@@ -571,17 +580,19 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
}
|
||||
|
||||
function streamPartEvents(
|
||||
state: { step: number; toolNames: Record<string, string> },
|
||||
state: StreamState,
|
||||
event: LanguageModelV3StreamPart,
|
||||
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
|
||||
switch (event.type) {
|
||||
case "stream-start":
|
||||
case "response-metadata":
|
||||
case "raw":
|
||||
case "file":
|
||||
case "source":
|
||||
case "tool-approval-request":
|
||||
return Effect.succeed([])
|
||||
case "raw":
|
||||
if (state.copilot) state.cost = copilotCost(event.rawValue) ?? state.cost
|
||||
return Effect.succeed([])
|
||||
case "text-start":
|
||||
return Effect.succeed([
|
||||
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
|
||||
@@ -672,16 +683,18 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "finish":
|
||||
const normalized = usage(event.usage, state.cost)
|
||||
state.cost = undefined
|
||||
return Effect.succeed([
|
||||
LLMEvent.stepFinish({
|
||||
index: state.step++,
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
usage: normalized,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
usage: normalized,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
@@ -690,7 +703,10 @@ function streamPartEvents(
|
||||
}
|
||||
}
|
||||
|
||||
function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"]): UsageInput | undefined {
|
||||
function usage(
|
||||
input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"],
|
||||
cost?: number,
|
||||
): UsageInput | undefined {
|
||||
const output = {
|
||||
inputTokens: input.inputTokens.total,
|
||||
nonCachedInputTokens: input.inputTokens.noCache,
|
||||
@@ -702,10 +718,22 @@ function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["us
|
||||
input.inputTokens.total === undefined || input.outputTokens.total === undefined
|
||||
? undefined
|
||||
: input.inputTokens.total + input.outputTokens.total,
|
||||
cost,
|
||||
}
|
||||
return Object.values(output).some((value) => value !== undefined) ? output : undefined
|
||||
}
|
||||
|
||||
function copilotCost(input: unknown): number | undefined {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
const raw = input
|
||||
const response = ProviderShared.isRecord(raw.response) ? raw.response : undefined
|
||||
const usage = raw.copilot_usage ?? response?.copilot_usage
|
||||
if (!ProviderShared.isRecord(usage)) return undefined
|
||||
const total = usage.total_nano_aiu
|
||||
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return undefined
|
||||
return total / 100_000_000_000
|
||||
}
|
||||
|
||||
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
|
||||
return value.unified === "other" ? "unknown" : value.unified
|
||||
}
|
||||
|
||||
@@ -272,10 +272,8 @@ const layer = Layer.effect(
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
||||
tokens: finish.tokens,
|
||||
})
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) =>
|
||||
SessionUsage.record(finish.usage, resolved.cost)
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface StepRecord {
|
||||
/** Present once the provider finished the step normally. */
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
readonly usage: Extract<LLMEvent, { type: "step-finish" }>["usage"]
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
readonly id: string
|
||||
@@ -495,7 +495,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
stepSettlement = { finish: event.reason.normalized, usage: event.usage }
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
|
||||
@@ -17,7 +17,6 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||
},
|
||||
})
|
||||
|
||||
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
|
||||
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
|
||||
const context = usage.input + usage.cache.read + usage.cache.write
|
||||
const tier = costs
|
||||
@@ -38,7 +37,14 @@ export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.
|
||||
|
||||
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
|
||||
const normalized = tokens(usage)
|
||||
return { tokens: normalized, cost: calculateCost(costs, normalized) }
|
||||
const reported = usage?.cost
|
||||
return {
|
||||
tokens: normalized,
|
||||
cost:
|
||||
reported !== undefined && Number.isFinite(reported) && reported >= 0
|
||||
? Money.USD.make(reported)
|
||||
: calculateCost(costs, normalized),
|
||||
}
|
||||
}
|
||||
|
||||
export const add = (a: Recorded, b: Recorded): Recorded => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
@@ -23,21 +23,26 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
const streamModel = (
|
||||
events: ReadonlyArray<LanguageModelV3StreamPart>,
|
||||
inspect?: (options: LanguageModelV3CallOptions) => void,
|
||||
): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () =>
|
||||
Promise.resolve({
|
||||
doStream: (options) => {
|
||||
inspect?.(options)
|
||||
return Promise.resolve({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
events.forEach((event) => controller.enqueue(event))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const usage = {
|
||||
@@ -375,6 +380,41 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes Copilot billed usage to USD", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let options: LanguageModelV3CallOptions | undefined
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel(
|
||||
[
|
||||
{
|
||||
type: "raw",
|
||||
rawValue: { type: "message_delta", copilot_usage: { total_nano_aiu: 4_473_525_000 } },
|
||||
},
|
||||
{ type: "finish", finishReason: { unified: "stop", raw: "end_turn" }, usage },
|
||||
],
|
||||
(input) => {
|
||||
options = input
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model({
|
||||
...model("@ai-sdk/github-copilot"),
|
||||
providerID: Provider.ID.githubCopilot,
|
||||
})
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
)
|
||||
|
||||
expect(options?.includeRawChunks).toBeTrue()
|
||||
expect(response.usage?.cost).toBeCloseTo(0.04473525)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { LLMEvent } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -13,6 +12,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||
|
||||
const sessionID = Session.ID.make("ses_tool_event_test")
|
||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
@@ -280,6 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 3,
|
||||
reasoningTokens: 1,
|
||||
cost: 1.25,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -289,13 +290,13 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
const settlement = publisher.record().finish
|
||||
expect(settlement).toMatchObject({
|
||||
finish: "content-filter",
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1, cost: 1.25 },
|
||||
})
|
||||
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||
const recorded = SessionUsage.record(settlement.usage, [])
|
||||
await Effect.runPromise(
|
||||
publisher.publishStepFailure({
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens: settlement.tokens,
|
||||
...recorded,
|
||||
snapshot: Snapshot.ID.make("tree-end"),
|
||||
files: [RelativePath.make("src/changed.ts")],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Usage } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||
|
||||
const costs = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
]
|
||||
|
||||
test("prefers provider-reported cost", () => {
|
||||
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0.25 }), costs).cost).toBe(
|
||||
Money.USD.make(0.25),
|
||||
)
|
||||
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0 }), costs).cost).toBe(Money.USD.zero)
|
||||
})
|
||||
|
||||
test("falls back to catalog pricing for invalid reported cost", () => {
|
||||
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: Number.NaN }), costs).cost).toBe(
|
||||
Money.USD.make(1),
|
||||
)
|
||||
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: -1 }), costs).cost).toBe(
|
||||
Money.USD.make(1),
|
||||
)
|
||||
})
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TuiStartupProvider,
|
||||
TuiTerminalEnvironmentProvider,
|
||||
useTuiApp,
|
||||
useTuiPaths,
|
||||
useTuiStartup,
|
||||
type TuiApp,
|
||||
} from "./context/runtime"
|
||||
@@ -85,6 +86,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
@@ -453,6 +455,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
const paths = useTuiPaths()
|
||||
const config = useConfig()
|
||||
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
|
||||
const route = useRoute()
|
||||
@@ -496,7 +499,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `MCP server failed: ${server.name}`,
|
||||
message: "Run /mcps to view details.",
|
||||
message: "Open MCP servers to view details.",
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -659,10 +662,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
location: newSessionLocation(
|
||||
config.data.session.new_location,
|
||||
paths.cwd,
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
),
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -384,16 +384,18 @@ export function DevToolsBar() {
|
||||
>
|
||||
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
<Show when={Boolean(turnTokens())}>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
</Show>
|
||||
</box>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
|
||||
@@ -93,6 +93,15 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "New session location",
|
||||
category: "Session",
|
||||
path: ["session", "new_location"],
|
||||
default: "launch",
|
||||
values: ["launch", "inherit"],
|
||||
labels: ["launch directory", "active session"],
|
||||
keywords: ["directory", "cwd", "inherit"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const copy = () => {
|
||||
void clipboard
|
||||
.write(props.error)
|
||||
.then(() => setCopied(true))
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }],
|
||||
}))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.name === "c") return copy()
|
||||
if (event.name === "up") return scroll?.scrollBy(-1)
|
||||
if (event.name === "down") return scroll?.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-height())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(height())
|
||||
if (event.name === "home") return scroll?.scrollTo(0)
|
||||
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<text fg={overlayTheme.text.default} wrapMode="word">
|
||||
{props.error}
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
@@ -6,10 +6,13 @@ import { pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogErrorDetails } from "./dialog-error-details"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
function statusError(status: McpServer["status"]) {
|
||||
if (status.status === "failed") return status.error
|
||||
@@ -140,9 +143,8 @@ export function DialogMcp() {
|
||||
}
|
||||
>
|
||||
{(server) => (
|
||||
<DialogErrorDetails
|
||||
title={`MCP server: ${server().name}`}
|
||||
error={statusError(server().status) ?? "Unknown MCP connection error"}
|
||||
<DialogMcpError
|
||||
server={server()}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
@@ -153,3 +155,79 @@ export function DialogMcp() {
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const copy = () => {
|
||||
void clipboard
|
||||
.write(error())
|
||||
.then(() => setCopied(true))
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
|
||||
}))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.name === "c") return copy()
|
||||
if (event.name === "up") return scroll?.scrollBy(-1)
|
||||
if (event.name === "down") return scroll?.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-height())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(height())
|
||||
if (event.name === "home") return scroll?.scrollTo(0)
|
||||
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<text fg={overlayTheme.text.default} wrapMode="word">
|
||||
{error()}
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import { marqueeText } from "../util/marquee"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
|
||||
const ADD_TAB_WIDTH = 3
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
|
||||
@@ -42,6 +44,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
|
||||
newTab?: () => boolean
|
||||
add?: () => void
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
@@ -103,22 +106,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
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 [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
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 activeID = createMemo(() => (newTab() ? undefined : 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 items = ordered
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
items().map((tab) => {
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
const status = tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
@@ -145,6 +149,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
|
||||
createEffect(() => {
|
||||
if (!scroll) return
|
||||
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
|
||||
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
|
||||
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
||||
if (index === -1) return
|
||||
const top = index * 3
|
||||
@@ -171,7 +177,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
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 session = createMemo(() => data.session.get(tab.sessionID))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
@@ -188,7 +194,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
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())
|
||||
})
|
||||
@@ -260,7 +265,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
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)
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
@@ -277,7 +282,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -386,7 +391,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseUp={(event) => {
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
@@ -417,6 +422,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
{/* One slot with two states: a subdued affordance that promotes in place into the
|
||||
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
|
||||
<Show when={tabs.add || newTab()}>
|
||||
<box
|
||||
height={1}
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
backgroundColor={
|
||||
newTab()
|
||||
? theme.background.action.primary.selected
|
||||
: addHovered()
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => {
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
|
||||
selectable={false}
|
||||
attributes={newTab() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
+
|
||||
</text>
|
||||
<text
|
||||
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={newTab() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{NEW_SESSION_TAB_TITLE}
|
||||
</text>
|
||||
<Show when={newTab()}>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
}}
|
||||
>
|
||||
{addHovered() ? "×" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
@@ -431,6 +493,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
@@ -449,7 +512,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
// The promoted new-session slot joins the strip as the active tab; the idle plus affordance
|
||||
// and the promoted slot are mutually exclusive states of one control.
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const showPlus = () => Boolean(tabs.add) && !newTab()
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
@@ -457,7 +523,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
adaptiveSessionTabLayout(
|
||||
items(),
|
||||
activeID(),
|
||||
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
|
||||
previous?.start,
|
||||
),
|
||||
)
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
@@ -704,7 +775,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
@@ -746,6 +817,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" " + layout().after}›
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={showPlus()}>
|
||||
<text
|
||||
width={ADD_TAB_WIDTH}
|
||||
fg={addHovered() ? theme.text.default : theme.text.subdued}
|
||||
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
|
||||
selectable={false}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => tabs.add?.()}
|
||||
>
|
||||
{" + "}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ export const Info = Schema.Struct({
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
new_location: Schema.optional(Schema.Literals(["launch", "inherit"])).annotate({
|
||||
description: "Start new sessions in the TUI launch directory or inherit the active session location",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
tabs: Schema.optional(
|
||||
@@ -202,7 +205,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "tabs"> & {
|
||||
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
@@ -218,6 +221,9 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
style: "block" | "underline" | "line" | "default"
|
||||
blinking: boolean
|
||||
}
|
||||
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
|
||||
new_location: "launch" | "inherit"
|
||||
}
|
||||
tabs: {
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
@@ -259,6 +265,10 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
blinking: input.cursor.blinking ?? true,
|
||||
}
|
||||
: undefined,
|
||||
session: {
|
||||
...input.session,
|
||||
new_location: input.session?.new_location ?? "launch",
|
||||
},
|
||||
tabs: {
|
||||
...input.tabs,
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
|
||||
export function newSessionLocation(
|
||||
mode: "launch" | "inherit",
|
||||
launchDirectory: string,
|
||||
current?: LocationRef,
|
||||
): LocationRef {
|
||||
if (mode === "inherit" && current) return current
|
||||
return { directory: launchDirectory }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useConfig } from "../config"
|
||||
import { useLocation } from "./location"
|
||||
import { useStorage } from "./storage"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
const location = useLocation()
|
||||
const paths = useTuiPaths()
|
||||
const enabled = () => config.tabs.enabled
|
||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||
@@ -249,6 +251,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
add() {
|
||||
if (!enabled()) return
|
||||
const sessionID = current()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
|
||||
})
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
|
||||
function Mcp(props: { context: Plugin.Context }) {
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
|
||||
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
|
||||
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
|
||||
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
|
||||
return (
|
||||
@@ -15,7 +14,6 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
{failed()} MCP failed
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span
|
||||
@@ -26,28 +24,11 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
>
|
||||
⊙{" "}
|
||||
</span>
|
||||
{count()} MCP
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={props.context.theme.text.subdued}>/mcps</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Plugins(props: { context: Plugin.Context }) {
|
||||
const plugins = usePlugin()
|
||||
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
{failed()} plugin{failed() === 1 ? "" : "s"} failed
|
||||
</text>
|
||||
<text fg={props.context.theme.text.subdued}>/plugins</text>
|
||||
<text fg={props.context.theme.text.subdued}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
@@ -69,7 +50,6 @@ function View(props: { context: Plugin.Context }) {
|
||||
gap={2}
|
||||
>
|
||||
<Mcp context={props.context} />
|
||||
<Plugins context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
|
||||
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
|
||||
const dialog = useDialog()
|
||||
const options = createMemo(() => {
|
||||
const builtins = props.plugins
|
||||
const options = createMemo(() =>
|
||||
props.plugins
|
||||
.registered()
|
||||
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
|
||||
.filter((plugin) => plugin.id !== id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id,
|
||||
value: plugin.id,
|
||||
category: "Built-in",
|
||||
category: plugin.source === "builtin" ? "Built-in" : "External",
|
||||
footer: (
|
||||
<span
|
||||
style={{
|
||||
@@ -33,43 +29,8 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
)
|
||||
const external = props.plugins.list().map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: "id" in plugin ? plugin.id : plugin.target,
|
||||
value: "id" in plugin ? plugin.id : plugin.target,
|
||||
category: "External",
|
||||
searchText: plugin.target,
|
||||
footer: (
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
plugin.status === "active"
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: plugin.status === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: props.context.theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{plugin.status}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
)
|
||||
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
|
||||
})
|
||||
|
||||
const failure = (value: string | undefined) =>
|
||||
props.plugins.list().find((plugin) => {
|
||||
if (plugin.status !== "failed") return false
|
||||
return ("id" in plugin ? plugin.id : plugin.target) === value
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (focused()) return
|
||||
const first = options()[0]
|
||||
if (first) setFocused(first.value)
|
||||
})
|
||||
),
|
||||
)
|
||||
|
||||
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||
if (locked()) return
|
||||
@@ -90,53 +51,15 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
|
||||
const select = (plugin: DialogSelectOption<string>) => {
|
||||
const failed = failure(plugin.value)
|
||||
if (!failed || failed.status !== "failed") return toggle(plugin)
|
||||
setDetail({ title: failed.target, error: failed.error })
|
||||
}
|
||||
|
||||
return (
|
||||
<box>
|
||||
<Show
|
||||
when={detail()}
|
||||
fallback={
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={options()}
|
||||
current={focused()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
onMove={(option) => setFocused(option.value)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "plugins.toggle",
|
||||
disabled: (option) => Boolean(failure(option?.value)),
|
||||
onTrigger: toggle,
|
||||
},
|
||||
]}
|
||||
onSelect={select}
|
||||
footer={
|
||||
<Show when={failure(focused())}>
|
||||
<text fg={props.context.theme.text.subdued}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<DialogErrorDetails
|
||||
title={`Plugin: ${item().title}`}
|
||||
error={item().error}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={options()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
|
||||
onSelect={toggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +72,6 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
id: "plugins.list",
|
||||
title: "Plugins",
|
||||
group: "System",
|
||||
slash: { name: "plugins" },
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||
|
||||
@@ -105,9 +105,21 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
}
|
||||
}
|
||||
|
||||
const addTab = () => {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (!next) {
|
||||
setLastEvent("all fixture tabs are open")
|
||||
return
|
||||
}
|
||||
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
|
||||
select(next.sessionID)
|
||||
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
|
||||
}
|
||||
|
||||
const controller = {
|
||||
tabs,
|
||||
current: active,
|
||||
add: addTab,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
|
||||
},
|
||||
@@ -283,21 +295,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
startRun(current)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "t",
|
||||
title: "Add tab",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (!next) {
|
||||
setLastEvent("all fixture tabs are open")
|
||||
return
|
||||
}
|
||||
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
|
||||
select(next.sessionID)
|
||||
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
|
||||
},
|
||||
},
|
||||
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
|
||||
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
|
||||
{
|
||||
bind: "r",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import { newSessionLocation } from "../config/new-session-location"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||
import {
|
||||
resolveMiniSettings,
|
||||
@@ -48,6 +49,7 @@ type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
|
||||
|
||||
type RunRuntimeInput = {
|
||||
host: MiniHost
|
||||
directory: string
|
||||
boot: () => Promise<BootContext>
|
||||
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
|
||||
createSession?: CreateSession
|
||||
@@ -941,7 +943,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
const created = await createSession(
|
||||
state.sdk,
|
||||
{
|
||||
location: state.location,
|
||||
location: newSessionLocation(
|
||||
(await tuiConfigTask).session.new_location,
|
||||
input.directory,
|
||||
state.location,
|
||||
),
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
@@ -1099,6 +1105,7 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
|
||||
return runInteractiveRuntime(
|
||||
{
|
||||
host: input.host,
|
||||
directory: input.directory,
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
|
||||
@@ -392,7 +392,7 @@ export type FormCancel = {
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session">
|
||||
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
|
||||
@@ -390,11 +390,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
|
||||
)
|
||||
)
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: `Plugin failed: ${state.target}`,
|
||||
message: "Run /plugins to view details.",
|
||||
})
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
||||
setStore("states", reconcileStore(states))
|
||||
}
|
||||
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
|
||||
|
||||
@@ -1222,6 +1222,11 @@ function TurnTokenUsage(props: {
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
// Collapsed by default: one summary line for the whole turn. Click to
|
||||
// open the full per-step table, click again to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const verbose = () => config.data.debug?.turn_tokens === "verbose"
|
||||
const steps = createMemo(() => {
|
||||
let previousCache = props.previousCache
|
||||
@@ -1257,49 +1262,78 @@ function TurnTokenUsage(props: {
|
||||
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
|
||||
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
|
||||
}))
|
||||
const summary = createMemo(() => {
|
||||
const items = steps()
|
||||
const last = items[items.length - 1]
|
||||
return {
|
||||
count: items.length,
|
||||
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
|
||||
cached: last?.cached ?? 0,
|
||||
total: last?.total ?? 0,
|
||||
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
|
||||
<box paddingLeft={3} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text width={INLINE_TOOL_ICON_WIDTH} fg={theme.text.subdued}>
|
||||
◈
|
||||
</text>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
|
||||
Tokens
|
||||
<box
|
||||
flexDirection="row"
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
>
|
||||
<text fg={hover() ? theme.text.default : theme.text.subdued} wrapMode="none">
|
||||
<span>{expanded() ? "- " : "+ "}</span>
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
|
||||
<span>
|
||||
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
|
||||
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
|
||||
</span>
|
||||
<Show when={summary().reuseDrops > 0}>
|
||||
<span style={{ fg: theme.text.feedback.warning.default }}>
|
||||
{" "}
|
||||
· ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
|
||||
{"Step".padEnd(columns().step + 2)}
|
||||
{"New".padStart(columns().newTokens)}
|
||||
{" "}
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
</span>
|
||||
{" "}
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
|
||||
{"Step".padEnd(columns().step + 2)}
|
||||
{"New".padStart(columns().newTokens)}
|
||||
{" "}
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
</span>
|
||||
{" "}
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ test("validates the session tabs setting", () => {
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
|
||||
expect(() => decode({ session: { new_location: "current" } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
@@ -45,6 +47,7 @@ test("resolves nested config and keybind defaults", () => {
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
expect(config.session.new_location).toBe("launch")
|
||||
})
|
||||
|
||||
test("shows resolved tab defaults in settings", () => {
|
||||
@@ -53,6 +56,10 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("shows the new session location default in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
let current = {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider, useClient } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||
@@ -86,9 +87,11 @@ async function renderSessionTabs(
|
||||
>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
@@ -272,3 +275,17 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("add opens the new session tab carrying the current session's location", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
|
||||
setup.tabs.add()
|
||||
expect(setup.route.data).toEqual({ type: "home", location: { directory } })
|
||||
await wait(() => setup.tabs.newTab())
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { newSessionLocation } from "../src/config/new-session-location"
|
||||
|
||||
test("uses the launch directory by default", () => {
|
||||
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
directory: "/launch",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits the active session location when configured", () => {
|
||||
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to the launch directory without an active session", () => {
|
||||
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
|
||||
})
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
Reference in New Issue
Block a user