mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e578ccf940 | ||
|
|
df05945042 | ||
|
|
6a99898ef7 | ||
|
|
a40a87276a | ||
|
|
a20cbc394e | ||
|
|
8fda87614f | ||
|
|
dffd95ce7c | ||
|
|
b0402f5a34 | ||
|
|
54b00ec5fe | ||
|
|
6dd1733bbf | ||
|
|
663c2dc1ce | ||
|
|
01eda4c178 | ||
|
|
a6b49b3f74 | ||
|
|
5b2276666f | ||
|
|
cc0cc59700 | ||
|
|
57a9decefe | ||
|
|
c0220ddd8b | ||
|
|
b31defc0a5 | ||
|
|
e7d42f83e6 | ||
|
|
db768c4886 | ||
|
|
9553187ba6 | ||
|
|
d04257eeb4 | ||
|
|
d68f425c17 | ||
|
|
49dd2cea34 | ||
|
|
fac875dba0 | ||
|
|
566ca864a0 |
+2
-2
@@ -5,10 +5,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec 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",
|
||||
|
||||
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
|
||||
HostedToolItem,
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
export type ExtendedHostedToolItem = {
|
||||
export type HostedToolReplayItem = {
|
||||
readonly type: string
|
||||
readonly id: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| ExtendedHostedToolItem
|
||||
| HostedToolReplayItem
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
export interface Extension {
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly lowerMedia?: (input: {
|
||||
@@ -381,10 +381,10 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
|
||||
|
||||
export interface ParserState {
|
||||
readonly id: string
|
||||
@@ -397,6 +397,9 @@ export interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedMessages: ReadonlySet<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
@@ -482,12 +485,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
@@ -507,17 +510,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
part: LLMRequest["messages"][number]["content"][number],
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
|
||||
})
|
||||
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
|
||||
const lowered = yield* lowerMedia(part, request, adapter, "message")
|
||||
if (lowered.type === "input_video")
|
||||
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
|
||||
return lowered
|
||||
})
|
||||
|
||||
@@ -526,13 +529,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
adapter,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
@@ -540,30 +543,33 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
adapter,
|
||||
)
|
||||
})
|
||||
|
||||
const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -571,13 +577,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (message.role === "system") {
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
@@ -644,7 +650,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
? undefined
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
: adapter.restoreHostedToolItem?.(part.result.value)
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
@@ -658,13 +664,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
|
||||
"text",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
@@ -677,11 +681,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request, extension),
|
||||
output: yield* lowerToolResultOutput(part, request, adapter),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -733,28 +737,28 @@ const allowedToolChoice = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, extension),
|
||||
input: yield* lowerMessages(request, adapter),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(
|
||||
extension.name,
|
||||
adapter.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -768,7 +772,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -951,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
|
||||
const phase = messagePhase(item.phase)
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
completedMessages.add(id)
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
@@ -969,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
@@ -1085,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
completedMessages.add(item.id)
|
||||
if (state.message !== undefined && state.message.id !== item.id)
|
||||
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
|
||||
const message = state.message
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
@@ -1098,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
completedMessages,
|
||||
message: undefined,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1408,9 +1422,9 @@ export const step = (state: ParserState, input: Event) => {
|
||||
* The provider-neutral Open Responses protocol. Provider-specific Responses
|
||||
* implementations compose this baseline with their own tools and event variants.
|
||||
*/
|
||||
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
|
||||
id: extension.id,
|
||||
name: extension.name,
|
||||
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
@@ -1418,6 +1432,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const extension = {
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
adapter,
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
|
||||
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const extension = {
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
|
||||
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles a done-only message once across replayed item events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Recovered" }],
|
||||
}
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Recovered")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Recovered",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
|
||||
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
|
||||
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const text = {
|
||||
type: "message",
|
||||
id: "msg_text",
|
||||
content: [{ type: "output_text", text: "Done-only text." }],
|
||||
}
|
||||
const refusal = {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
content: [{ type: "refusal", refusal: "Done-only refusal." }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_text",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_text",
|
||||
text: "Done-only text.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_refusal",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_refusal",
|
||||
text: "Done-only refusal.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a repeated message lifecycle as replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores a stale done-only message while another message is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Draft" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text: "Final",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
})
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
|
||||
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[data-component="composer-editor"]:empty::before {
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<form
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01": props.borderUnderlay,
|
||||
"bg-v2-background-bg-base": !props.borderUnderlay,
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
[data-slot="mobile-drawer-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--v2-overlay-simple-overlay-scrim);
|
||||
animation: mobile-drawer-backdrop-in 240ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-content"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--v2-background-bg-deep);
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
outline: none;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-content"][data-transitioning] {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-content"][data-closing] {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-content"][data-closed] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-handle"] {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-handle"] span {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
@keyframes mobile-drawer-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-drawer-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="mobile-drawer-content"][data-transitioning],
|
||||
[data-slot="mobile-drawer-content"][data-closing] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-drawer-overlay"],
|
||||
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Drawer from "@corvu/drawer"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./mobile-drawer.css"
|
||||
|
||||
export function MobileDrawer(
|
||||
props: ParentProps<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onContentPresentChange?: (present: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
closeOnOutsideFocus?: boolean
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<Drawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
onContentPresentChange={props.onContentPresentChange}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
closeOnOutsideFocus={props.closeOnOutsideFocus}
|
||||
>
|
||||
{props.children}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileDrawerTrigger = Drawer.Trigger
|
||||
|
||||
export function MobileDrawerContent(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
|
||||
<div data-slot="mobile-drawer-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
{props.children}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileDrawerLabel = Drawer.Label
|
||||
export const MobileDrawerClose = Drawer.Close
|
||||
@@ -0,0 +1,34 @@
|
||||
[data-slot="mobile-panel"] {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="mobile-panel-header"] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-inline-start: 8px;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-panel-header"] h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
[data-slot="mobile-panel-close"][data-component="button-v2"] {
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="mobile-panel-content"] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Drawer from "@corvu/drawer"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./status/status-drawer.css"
|
||||
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
|
||||
import "./mobile-panel-drawer.css"
|
||||
|
||||
export function MobilePanelDrawer(
|
||||
props: ParentProps<{
|
||||
@@ -13,32 +14,29 @@ export function MobilePanelDrawer(
|
||||
) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Drawer
|
||||
<MobileDrawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
returnFocus={props.returnFocus}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-status-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
|
||||
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-status-header" data-corvu-no-drag>
|
||||
<Drawer.Label>{props.title}</Drawer.Label>
|
||||
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
|
||||
<MobileDrawerContent>
|
||||
<div data-slot="mobile-panel" data-corvu-no-drag>
|
||||
<div data-slot="mobile-panel-header">
|
||||
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
|
||||
<MobileDrawerClose
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
data-slot="mobile-panel-close"
|
||||
aria-label={language.t("common.close")}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</Drawer.Close>
|
||||
</MobileDrawerClose>
|
||||
</div>
|
||||
<div data-slot="mobile-status-content" data-corvu-no-drag>
|
||||
{props.children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
<div data-slot="mobile-panel-content">{props.children}</div>
|
||||
</div>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,109 +1,3 @@
|
||||
[data-slot="mobile-status-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--v2-overlay-simple-overlay-scrim);
|
||||
animation: mobile-status-backdrop-in 240ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: mobile-status-backdrop-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--v2-background-bg-deep);
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
outline: none;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-transitioning] {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closed] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] span {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-inline-start: 8px;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"] {
|
||||
min-height: 44px;
|
||||
flex-shrink: 0;
|
||||
padding-inline: 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-slot="mobile-status-close"]:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-content"] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-loading"] {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
@@ -113,33 +7,3 @@
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="mobile-status-drawer"][data-transitioning],
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"],
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { MobilePanelDrawer } from "../mobile-panel-drawer"
|
||||
import "./status-drawer.css"
|
||||
|
||||
const Body = lazy(async () => {
|
||||
const { StatusPopoverBody } = await import("./body")
|
||||
|
||||
@@ -14,63 +14,13 @@
|
||||
var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--v2-overlay-simple-overlay-scrim);
|
||||
animation: mobile-tabs-backdrop-in 240ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
|
||||
[data-slot="mobile-tabs-drawer"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--v2-background-bg-deep);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"][data-transitioning] {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"][data-closing] {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"][data-closed] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drag-handle"] {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drag-handle"] span {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-border-border-strong);
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer-list"] {
|
||||
@@ -79,36 +29,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes mobile-tabs-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-tabs-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="mobile-tabs-drawer"][data-transitioning],
|
||||
[data-slot="mobile-tabs-drawer"][data-closing] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-overlay"],
|
||||
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
import Drawer from "@corvu/drawer"
|
||||
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
@@ -415,7 +415,7 @@ export function Titlebar(props: {
|
||||
<Show
|
||||
when={!mobile()}
|
||||
fallback={
|
||||
<Drawer
|
||||
<MobileDrawer
|
||||
open={mobileTabs.open}
|
||||
onOpenChange={(open) => setMobileTabs("open", open)}
|
||||
onContentPresentChange={(present) => {
|
||||
@@ -423,11 +423,9 @@ export function Titlebar(props: {
|
||||
setMobileTabs("settings", false)
|
||||
openSettings()
|
||||
}}
|
||||
side="bottom"
|
||||
>
|
||||
<Drawer.Trigger
|
||||
<MobileDrawerTrigger
|
||||
data-slot="mobile-tabs-trigger"
|
||||
aria-expanded={mobileTabs.open}
|
||||
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
|
||||
aria-label={language.t("titlebar.tabs")}
|
||||
>
|
||||
@@ -467,15 +465,11 @@ export function Titlebar(props: {
|
||||
{currentTitle()}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
|
||||
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
|
||||
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
|
||||
</MobileDrawerTrigger>
|
||||
<MobileDrawerContent>
|
||||
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
|
||||
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
|
||||
<div data-slot="mobile-tabs-drawer-list">
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
@@ -493,7 +487,6 @@ export function Titlebar(props: {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-corvu-no-drag
|
||||
data-action="mobile-tabs-new-session"
|
||||
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
|
||||
onClick={() => {
|
||||
@@ -504,10 +497,7 @@ export function Titlebar(props: {
|
||||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
|
||||
data-corvu-no-drag
|
||||
>
|
||||
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-tabs-home"
|
||||
@@ -546,9 +536,9 @@ export function Titlebar(props: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
</div>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
|
||||
import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { Updater } from "../services/updater"
|
||||
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
@@ -56,6 +57,20 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.choice("method", Updater.methods).pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Installation method to use"),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { handlePromptErrors } from "../../ui/prompt"
|
||||
import { OPENCODE_VERSION } from "../../version"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.upgrade,
|
||||
Effect.fn("cli.upgrade")(function* (input) {
|
||||
intro("Upgrade")
|
||||
const updater = yield* Updater.Service
|
||||
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
|
||||
if (!method)
|
||||
return yield* Effect.fail(
|
||||
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`From ${OPENCODE_VERSION} → ${version}`)
|
||||
const progress = spinner()
|
||||
progress.start("Upgrading...")
|
||||
yield* updater.upgrade(method, target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
}, handlePromptErrors),
|
||||
)
|
||||
@@ -17,6 +17,7 @@ import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
upgrade: () => import("./commands/handlers/upgrade"),
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { ReadStream } from "node:tty"
|
||||
import { OPENCODE_VERSION } from "./version"
|
||||
|
||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||
|
||||
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
|
||||
argv: process.argv.slice(2),
|
||||
}
|
||||
return {
|
||||
version: OPENCODE_VERSION,
|
||||
terminal: { stdin: input.terminal.stdin },
|
||||
platform: process.platform,
|
||||
stdout: {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
return policy === "notify" ? "notify" : "upgrade"
|
||||
}
|
||||
|
||||
function parseReleaseVersion(input: string) {
|
||||
export function parseReleaseVersion(input: string) {
|
||||
if (input.length > 256) return
|
||||
const match = input.trim().match(versionPattern)
|
||||
if (!match) return
|
||||
|
||||
@@ -5,19 +5,21 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
|
||||
? OPENCODE_CLI_NAME
|
||||
: "@opencode-ai/cli"
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -110,7 +112,9 @@ export const layer = Layer.effect(
|
||||
return data.version
|
||||
})
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
@@ -138,7 +142,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
)
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
@@ -173,7 +177,7 @@ export const layer = Layer.effect(
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
|
||||
return Service.of({ check })
|
||||
return Service.of({ check, method, latest, upgrade })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -486,7 +486,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||
draft.mini = {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -494,7 +501,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||
mini: {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
},
|
||||
})
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { Commands } from "../../src/commands/commands"
|
||||
import upgrade from "../../src/commands/handlers/upgrade"
|
||||
import { Updater } from "../../src/services/updater"
|
||||
|
||||
const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`)
|
||||
|
||||
await Effect.runPromise(
|
||||
Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
|
||||
}),
|
||||
latest: () =>
|
||||
Effect.suspend(() => {
|
||||
record("latest")
|
||||
return process.env.UPGRADE_TEST_LATEST_ERROR
|
||||
? Effect.fail(new Error("Update check failed"))
|
||||
: Effect.succeed("0.0.0-beta-new")
|
||||
}),
|
||||
upgrade: (method, version) =>
|
||||
Effect.suspend(() => {
|
||||
record({ method, version })
|
||||
return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void
|
||||
}),
|
||||
}),
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type InteractiveStdin,
|
||||
usingInteractiveStdin,
|
||||
} from "../src/mini-host"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const model = { providerID: "openai", modelID: "gpt-5" }
|
||||
@@ -145,6 +146,7 @@ describe("Mini CLI host", () => {
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
|
||||
|
||||
expect(input.paths).toEqual({ home: directory.path })
|
||||
expect(input.version).toBe(OPENCODE_VERSION)
|
||||
expect(input.platform).toBe(process.platform)
|
||||
expect(typeof input.files.readText).toBe("function")
|
||||
const file = path.join(directory.path, "attachment.txt")
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { Updater } from "../src/services/updater"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
|
||||
const it = testEffect(NodeServices.layer)
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
function fixture(
|
||||
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const global = Global.make({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
cache: path.join(root, "cache"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
bin: path.join(root, "bin"),
|
||||
log: path.join(root, "log"),
|
||||
repos: path.join(root, "repos"),
|
||||
})
|
||||
const commands: string[][] = []
|
||||
const updater = yield* Updater.Service.pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(
|
||||
AppProcess.Service,
|
||||
AppProcess.Service.of({
|
||||
...spawner,
|
||||
run: (command) =>
|
||||
Effect.suspend(() => {
|
||||
if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command")
|
||||
commands.push([command.command, ...command.args])
|
||||
const result = respond(command)
|
||||
if (result.error) return Effect.fail(result.error)
|
||||
return Effect.succeed({
|
||||
command: command.command,
|
||||
exitCode: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
...result,
|
||||
})
|
||||
}),
|
||||
runStream: () => Stream.die("Unexpected streaming install command"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { updater, commands, global, fs }
|
||||
})
|
||||
}
|
||||
|
||||
const installs = [
|
||||
{ method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] },
|
||||
{
|
||||
method: "pnpm",
|
||||
command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"],
|
||||
},
|
||||
{ method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] },
|
||||
] as const
|
||||
|
||||
installs.forEach(({ method, command }) => {
|
||||
it.live(`${method} installs the explicit V2 package version without a leading v`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
|
||||
expect(test.commands).toEqual([[...command]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
;[0, 1].forEach((exitCode) => {
|
||||
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => {
|
||||
expect(command.command).toBe("bun")
|
||||
expect(existsSync(command.args[4])).toBe(true)
|
||||
return { exitCode, stderr: Buffer.from("bun install failed") }
|
||||
})
|
||||
const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
|
||||
const cache = test.commands[0]?.[5]
|
||||
expect(cache).toStartWith(path.join(test.global.cache, "update-"))
|
||||
expect(test.commands).toEqual([
|
||||
["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"],
|
||||
])
|
||||
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
|
||||
expect(result._tag).toBe(exitCode === 0 ? "None" : "Some")
|
||||
if (result._tag === "Some") expect(result.value.message).toBe("bun install failed")
|
||||
}),
|
||||
)
|
||||
})
|
||||
;["success", "download", "install"].forEach((failure) => {
|
||||
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => {
|
||||
const installer = command.command === "curl" ? command.args[2] : command.args[0]
|
||||
expect(existsSync(path.dirname(installer))).toBe(true)
|
||||
return {
|
||||
exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0,
|
||||
stderr: Buffer.from(`${failure} failed`),
|
||||
}
|
||||
})
|
||||
const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
|
||||
const installer = test.commands[0]?.[3]
|
||||
expect(installer).toStartWith(path.join(test.global.cache, "update-"))
|
||||
expect(test.commands).toEqual([
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]),
|
||||
])
|
||||
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
|
||||
expect(result._tag).toBe(failure === "success" ? "None" : "Some")
|
||||
if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("invalid version targets never execute a command or create a cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
yield* Effect.forEach(Updater.methods, (method) =>
|
||||
Effect.forEach(
|
||||
["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"],
|
||||
(version) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip)
|
||||
expect(error.message).toBe(`Invalid version: ${version}`)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(test.commands).toEqual([])
|
||||
expect(yield* test.fs.exists(test.global.cache)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("install failures expose stderr and process errors do not report success", () =>
|
||||
Effect.gen(function* () {
|
||||
const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") }))
|
||||
const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
|
||||
expect(error.message).toBe("registry denied access")
|
||||
const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) }))
|
||||
const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
|
||||
expect(unavailable.message).toBe("Failed to update with npm")
|
||||
expect(failed.commands).toHaveLength(1)
|
||||
expect(missing.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
|
||||
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === method ? "@opencode-ai/cli@2.3.4" : "opencode-ai@1.0.0"),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe(method)
|
||||
expect(test.commands).toEqual([
|
||||
["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("method detection tolerates unavailable package managers", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) =>
|
||||
command.command === "yarn"
|
||||
? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") }
|
||||
: { error: new AppProcess.AppProcessError({ command: command.command }) },
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("yarn")
|
||||
expect(test.commands).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
test("Node distribution honors the compile-time CLI name", async () => {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"test",
|
||||
import.meta.path,
|
||||
"--define",
|
||||
'OPENCODE_CLI_NAME="opencode2-node"',
|
||||
"--test-name-pattern",
|
||||
"^Node distribution resolves the published npm package$",
|
||||
],
|
||||
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
|
||||
)
|
||||
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
||||
expect(code, stderr).toBe(0)
|
||||
expect(stderr).toContain("1 pass")
|
||||
})
|
||||
|
||||
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
|
||||
it.live("Node distribution resolves the published npm package", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
expect(test.commands).toEqual([
|
||||
["npm", "list", "-g", "--depth=0", "opencode-node"],
|
||||
["pnpm", "list", "-g", "--depth=0", "opencode-node"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["npm", "install", "--global", "opencode-node@2.3.4"],
|
||||
["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
describe("upgrade command", () => {
|
||||
test("is registered in root help and documents its options", async () => {
|
||||
const root = await cli(["--help"], {}, "../src/index.ts")
|
||||
const help = await cli(["upgrade", "--help"], {}, "../src/index.ts")
|
||||
expect(root.exitCode).toBe(0)
|
||||
expect(root.stdout).toContain("upgrade")
|
||||
expect(help.exitCode).toBe(0)
|
||||
expect(help.stdout).toContain("[<target>]")
|
||||
expect(help.stdout).toContain("--method")
|
||||
expect(help.stdout).toContain("-m")
|
||||
})
|
||||
|
||||
test("detects the installation method and resolves the latest version", async () => {
|
||||
const result = await cli([])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
|
||||
expect(result.stdout).toContain("Upgrade complete")
|
||||
})
|
||||
|
||||
test("accepts an explicit version and method without detection or a version lookup", async () => {
|
||||
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }])
|
||||
expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target")
|
||||
})
|
||||
|
||||
test("accepts the short method flag and an explicit major upgrade", async () => {
|
||||
const result = await cli(["2.0.0", "-m", "bun"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("skips the already installed version", async () => {
|
||||
const result = await cli(["v0.0.0-beta-old"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual(["method"])
|
||||
expect(result.stdout).toContain("already installed")
|
||||
})
|
||||
|
||||
test("requires an explicit method when detection fails", async () => {
|
||||
const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.events).toEqual(["method"])
|
||||
expect(result.stdout).toContain("Pass --method")
|
||||
})
|
||||
|
||||
test("rejects unsupported methods before attempting an upgrade", async () => {
|
||||
const result = await cli(["--method", "brew"])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.events).toEqual([])
|
||||
})
|
||||
|
||||
test("reports version lookup failures without installing", async () => {
|
||||
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.events).toEqual(["method", "latest"])
|
||||
expect(result.stdout).toContain("Update check failed")
|
||||
})
|
||||
|
||||
test("reports installation failures with a nonzero exit code", async () => {
|
||||
const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Upgrade failed")
|
||||
expect(result.stdout).toContain("Permission denied")
|
||||
expect(result.stdout).not.toContain("Upgrade complete")
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[], env: Record<string, string> = {}, entry = "fixture/upgrade.ts") {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-"))
|
||||
try {
|
||||
const child = Bun.spawn(
|
||||
[process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args],
|
||||
{
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
...env,
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
const events = stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("EVENT "))
|
||||
.map((line) => JSON.parse(line.slice(6)))
|
||||
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
|
||||
return { stdout, stderr, exitCode, events }
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
|
||||
type Client = ReturnType<typeof OpenCode.make>
|
||||
|
||||
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
|
||||
export type { PermissionCreateInput } from "./generated/types.js"
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
|
||||
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
||||
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const lookupOrder = Tool.make({
|
||||
@@ -60,9 +60,22 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
|
||||
|
||||
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
|
||||
|
||||
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
|
||||
`tools.issues.list(...)`. Other characters use bracket notation, such as
|
||||
`tools.context7["resolve-library-id"](...)`.
|
||||
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
orders: Namespace.make({
|
||||
description: "Purchases, fulfillment, and shipment tracking",
|
||||
tools: { lookup: lookupOrder },
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come
|
||||
from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use
|
||||
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
|
||||
|
||||
### `CodeMode.execute` and `CodeMode.make`
|
||||
|
||||
@@ -150,7 +163,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms.
|
||||
|
||||
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
|
||||
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
|
||||
`maxToolCalls`.
|
||||
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
|
||||
|
||||
## Execution Limits
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * as CodeMode from "./codemode.js"
|
||||
export * as Namespace from "./namespace.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { searchSignature, toolExpression } from "./codemode.js"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
CodeModeFunction,
|
||||
CodeModeGenerator,
|
||||
CoercionFunction,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
GeneratorMethodReference,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
IteratorSymbol,
|
||||
JsonMethodReference,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
@@ -42,13 +44,12 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof SymbolNamespace ||
|
||||
isCodeModeValue(value)
|
||||
|
||||
function* childValues(value: object): Generator<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
const length = value.length
|
||||
for (let index = 0; index < length; index++) yield value[index]
|
||||
return
|
||||
function* childValues(value: object): Generator {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
|
||||
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
yield Reflect.get(value, key)
|
||||
}
|
||||
yield* Object.values(value)
|
||||
}
|
||||
|
||||
export const containsRuntimeReference = (value: unknown): boolean => {
|
||||
@@ -90,9 +91,14 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
||||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
@@ -104,7 +110,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool namespace with optional model-visible metadata. */
|
||||
export type Namespace<R = never> = {
|
||||
readonly _tag: "CodeModeNamespace"
|
||||
readonly description?: string
|
||||
readonly tools: Tools<R>
|
||||
}
|
||||
|
||||
/** Options for declaring one CodeMode namespace. */
|
||||
export type Options<R = never> = {
|
||||
readonly description?: string
|
||||
readonly tools: Tools<R>
|
||||
}
|
||||
|
||||
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
|
||||
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
|
||||
|
||||
/** Declares a namespace when descriptions or other namespace metadata are needed. */
|
||||
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
|
||||
_tag: "CodeModeNamespace",
|
||||
...(options.description === undefined ? {} : { description: options.description }),
|
||||
tools: options.tools,
|
||||
})
|
||||
@@ -53,7 +53,6 @@ export const fromSpec = (options: Options): Result => {
|
||||
if (!isRecord(pathValue)) continue
|
||||
for (const [method, operationValue] of Object.entries(pathValue)) {
|
||||
if (!methods.has(method) || !isRecord(operationValue)) continue
|
||||
const segments = operationPath(method, path, operationValue, used, namespaces)
|
||||
const operation: Operation = {
|
||||
operationId: nonEmptyString(operationValue.operationId),
|
||||
method: method.toUpperCase(),
|
||||
@@ -99,6 +98,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
auth: options.auth,
|
||||
headers: options.headers ?? {},
|
||||
}
|
||||
const segments = operationPath(method, path, operationValue, used, namespaces)
|
||||
used.add(segments.join("."))
|
||||
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
|
||||
setTool(
|
||||
|
||||
@@ -461,9 +461,7 @@ export const operationInput = (
|
||||
const fields = [...parameters.value, ...requestBody.value.fields]
|
||||
|
||||
const conflicts = new Set(
|
||||
[...Map.groupBy(fields, (field) => field.name)]
|
||||
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
|
||||
.map(([name]) => name),
|
||||
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
|
||||
)
|
||||
const used = new Set<string>()
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
InterpreterRuntimeError,
|
||||
IteratorSymbol,
|
||||
IteratorSymbols,
|
||||
} from "../interpreter/model.js"
|
||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
@@ -37,10 +31,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
}
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys":
|
||||
return Object.keys(requireObject())
|
||||
@@ -64,14 +54,29 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
const seen = new Set<object>()
|
||||
const guardedSet = (key: PropertyKey, item: unknown): void => {
|
||||
if (typeof key === "string" && isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
|
||||
if (!Reflect.set(out, key, item))
|
||||
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
|
||||
"TypeError",
|
||||
)
|
||||
}
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
|
||||
for (const key of Reflect.ownKeys(source)) {
|
||||
if (typeof key === "string") {
|
||||
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
|
||||
continue
|
||||
}
|
||||
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
|
||||
guardedSet(key, Reflect.get(source, key))
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
inputTypeScript,
|
||||
outputTypeScript,
|
||||
} from "./tool-schema.js"
|
||||
import { isNamespace, type Namespace } from "./namespace.js"
|
||||
import { isTool, type Tool } from "./tool.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
import {
|
||||
@@ -277,6 +278,7 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
||||
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
|
||||
type ToolNode<R> = {
|
||||
tool?: Tool<R>
|
||||
namespace?: Namespace<R>
|
||||
readonly children: Map<string, ToolNode<R>>
|
||||
}
|
||||
|
||||
@@ -292,7 +294,10 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
current = child
|
||||
}
|
||||
if (isTool<R>(value)) current.tool = value
|
||||
else insert(current, value)
|
||||
else if (isNamespace<R>(value)) {
|
||||
current.namespace = value
|
||||
insert(current, value.tools)
|
||||
} else insert(current, value)
|
||||
}
|
||||
}
|
||||
insert(root, tools)
|
||||
@@ -302,29 +307,33 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
||||
path.flatMap((segment) => segment.split("."))
|
||||
|
||||
type VisibleTool<R> = {
|
||||
readonly path: string
|
||||
readonly tool: Tool<R>
|
||||
readonly namespaces: ReadonlyArray<Namespace<R>>
|
||||
}
|
||||
|
||||
const flattenTools = <R>(
|
||||
node: ToolNode<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; tool: Tool<R> }> => [
|
||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
|
||||
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
|
||||
]
|
||||
namespaces: ReadonlyArray<Namespace<R>> = [],
|
||||
): Array<VisibleTool<R>> => {
|
||||
const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace]
|
||||
return [
|
||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]),
|
||||
...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
|
||||
]
|
||||
}
|
||||
|
||||
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
||||
path,
|
||||
description: tool.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools))
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -420,12 +429,13 @@ export const searchSignature = (() => {
|
||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
})()
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
description: describeTool(visible),
|
||||
searchText: [
|
||||
path,
|
||||
tool.description,
|
||||
...inputProperties(tool).flatMap(({ name, description: property }) =>
|
||||
visible.path,
|
||||
visible.tool.description,
|
||||
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
|
||||
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property],
|
||||
),
|
||||
]
|
||||
@@ -433,14 +443,13 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
|
||||
const visible = visibleTools(tools)
|
||||
return {
|
||||
catalog: visible.map(({ description }) => description),
|
||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
||||
catalog: visible.map(describeTool),
|
||||
searchIndex: visible.map(toSearchEntry),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,18 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
} catch {}
|
||||
}
|
||||
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
||||
if (schema.type === "integer") tags.push("@integer")
|
||||
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
|
||||
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
|
||||
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
|
||||
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
|
||||
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
|
||||
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
|
||||
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
|
||||
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
|
||||
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
||||
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
|
||||
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
|
||||
return tags
|
||||
}
|
||||
|
||||
@@ -127,8 +137,8 @@ const renderSchema = (
|
||||
])
|
||||
}
|
||||
if (schema.allOf) {
|
||||
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
|
||||
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
|
||||
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
|
||||
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Namespace } from "./namespace.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
/**
|
||||
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
|
||||
@@ -19,8 +21,17 @@ export type JsonSchema = {
|
||||
readonly default?: unknown
|
||||
readonly format?: string
|
||||
readonly deprecated?: boolean
|
||||
readonly minimum?: number
|
||||
readonly maximum?: number
|
||||
readonly exclusiveMinimum?: number
|
||||
readonly exclusiveMaximum?: number
|
||||
readonly multipleOf?: number
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
readonly minItems?: number
|
||||
readonly maxItems?: number
|
||||
readonly uniqueItems?: boolean
|
||||
readonly $ref?: string
|
||||
readonly $defs?: Readonly<Record<string, JsonSchema>>
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
@@ -50,13 +61,8 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
|
||||
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||
}
|
||||
|
||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
|
||||
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"_tag" in value &&
|
||||
Object.hasOwn(value, "_tag") &&
|
||||
value._tag === "CodeModeTool"
|
||||
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
|
||||
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
|
||||
|
||||
/**
|
||||
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Namespace } from "./namespace.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
export type Tools<R = never> = {
|
||||
readonly [name: string]: Tool<R> | Tools<R>
|
||||
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
|
||||
}
|
||||
|
||||
@@ -25,8 +25,12 @@ const happyPathSpec = async (): Promise<Document> => {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const toolAt = (tools: unknown, name: string) =>
|
||||
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
|
||||
const toolAt = (tools: OpenAPI.Tools, name: string) =>
|
||||
name
|
||||
.split(".")
|
||||
.reduce<
|
||||
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
|
||||
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
|
||||
|
||||
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
|
||||
const requests: Array<Recorded> = []
|
||||
@@ -278,6 +282,30 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
|
||||
})
|
||||
|
||||
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
|
||||
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
|
||||
for (const unsupported of [false, true]) {
|
||||
const result = OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: {
|
||||
openapi: "3.1.0",
|
||||
paths: {
|
||||
"/first": { get: operation },
|
||||
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
|
||||
"/last": { get: operation },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
|
||||
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
|
||||
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
|
||||
expect(result.skipped).toEqual(
|
||||
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("synthesizes flat operation IDs from methods and paths", () => {
|
||||
const response = { responses: { 200: { description: "Success" } } }
|
||||
const tools = OpenAPI.fromSpec({
|
||||
@@ -315,7 +343,10 @@ describe("OpenAPI.fromSpec", () => {
|
||||
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
|
||||
get: {
|
||||
operationId: "test",
|
||||
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
|
||||
parameters: [
|
||||
{ name: "limit", in: "query", schema: { type: "boolean" } },
|
||||
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
|
||||
],
|
||||
responses: { 200: { description: "Success" } },
|
||||
},
|
||||
},
|
||||
@@ -948,7 +979,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(spec.security).toStrictEqual([])
|
||||
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
|
||||
const health = toolAt(result.tools, "v2.health.get")
|
||||
const healthInput = isRecord(health) ? health.input : undefined
|
||||
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
|
||||
expect(healthInput).toMatchObject({ type: "object", properties: {} })
|
||||
const input = isRecord(healthInput) ? healthInput : {}
|
||||
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
|
||||
|
||||
@@ -139,6 +139,81 @@ describe("pretty signature rendering", () => {
|
||||
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
|
||||
})
|
||||
|
||||
test.each([
|
||||
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
|
||||
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
|
||||
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
|
||||
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
|
||||
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
|
||||
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
|
||||
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
|
||||
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
|
||||
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
|
||||
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
|
||||
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
|
||||
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
|
||||
const schema = { type: "object", properties: { value } }
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
|
||||
})
|
||||
|
||||
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer" },
|
||||
amount: { type: "number" },
|
||||
name: { type: "string" },
|
||||
enabled: { type: "boolean" },
|
||||
values: { type: "array", uniqueItems: false },
|
||||
choice: { type: ["integer", "string"] },
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** @integer */",
|
||||
" count?: number,",
|
||||
" amount?: number,",
|
||||
" name?: string,",
|
||||
" enabled?: boolean,",
|
||||
" values?: Array<unknown>,",
|
||||
" choice?: number | string,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
|
||||
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
|
||||
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
|
||||
)
|
||||
})
|
||||
|
||||
test("escapes comment terminators in tag values", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
' * @default "* /"',
|
||||
" * @format * /",
|
||||
" * @pattern ^a* /b$",
|
||||
" */",
|
||||
" value?: string,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
|
||||
@@ -344,33 +419,100 @@ describe("union schemas render every alternative", () => {
|
||||
expect(outputTypeScript(tool)).toBe("number | boolean")
|
||||
})
|
||||
|
||||
test("allOf renders intersections with parenthesized union members", () => {
|
||||
test("allOf keeps siblings and parenthesized union members in order", () => {
|
||||
const schema = {
|
||||
properties: { common: { type: "boolean" } },
|
||||
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
|
||||
} as const
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("allOf does not discard an unresolved constraint", () => {
|
||||
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
|
||||
"unknown",
|
||||
)
|
||||
test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => {
|
||||
for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) {
|
||||
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown")
|
||||
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown")
|
||||
expect(
|
||||
jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty),
|
||||
).toBe("unknown")
|
||||
}
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
|
||||
}),
|
||||
).toBe("unknown")
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
type: "string",
|
||||
allOf: [{ $ref: "#/$defs/Constraint" }],
|
||||
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
|
||||
}),
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "string",
|
||||
allOf: [{ $ref: "#/$defs/Constraint" }],
|
||||
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
|
||||
},
|
||||
pretty,
|
||||
),
|
||||
).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSDoc signatures in catalogs and search results", () => {
|
||||
test.each([
|
||||
{
|
||||
source: "JSON Schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: 0, maximum: 10 },
|
||||
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
|
||||
labels: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
|
||||
},
|
||||
required: ["count", "name", "labels"],
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "Effect",
|
||||
schema: Schema.Struct({
|
||||
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
|
||||
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
|
||||
labels: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(5)),
|
||||
}),
|
||||
},
|
||||
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
constrained: Tool.make({
|
||||
description: "Constrained tool",
|
||||
input: schema,
|
||||
output: schema,
|
||||
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"] }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const type = [
|
||||
"{",
|
||||
" /**",
|
||||
" * @integer",
|
||||
" * @minimum 0",
|
||||
" * @maximum 10",
|
||||
" */",
|
||||
" count: number,",
|
||||
" /**",
|
||||
" * @minLength 1",
|
||||
" * @maxLength 20",
|
||||
" * @pattern ^[a-z]+$",
|
||||
" */",
|
||||
" name: string,",
|
||||
" /**",
|
||||
" * @minItems 1",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" labels: Array<string>,",
|
||||
"}",
|
||||
].join("\n")
|
||||
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
|
||||
expect(runtime.catalog()[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
||||
|
||||
const search = async (query: string) => {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
|
||||
import { invokeObjectMethod } from "../src/stdlib/object.js"
|
||||
|
||||
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
|
||||
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
@@ -824,6 +826,174 @@ describe("stdlib integration", () => {
|
||||
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
|
||||
})
|
||||
|
||||
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(target).toEqual({ nested })
|
||||
})
|
||||
|
||||
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
|
||||
const target = {}
|
||||
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(Object.hasOwn(target, "nested")).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign cycle checks traverse sparse keys lazily", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperties([], {
|
||||
4294967294: { enumerable: true, value: target },
|
||||
later: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign stops after a supported symbol write fails", () => {
|
||||
const previous = () => ({ done: true })
|
||||
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
|
||||
[AsyncIteratorSymbol]: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return () => ({ done: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign could not assign property",
|
||||
)
|
||||
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign rejects direct and nested cycles", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
try { Object.assign(target, { self: target }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
const nested = { target }
|
||||
try { Object.assign(target, { nested }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = target
|
||||
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const nested = {}
|
||||
nested[Symbol.iterator] = target
|
||||
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign preserves mutations before a circular field", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ before: 1 })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const marker = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = marker
|
||||
source[Symbol.asyncIterator] = target
|
||||
try { Object.assign(target, source) } catch {
|
||||
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
|
||||
}
|
||||
return null
|
||||
`),
|
||||
).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shared = { count: 1 }
|
||||
const target = {}
|
||||
const result = Object.assign(target, { left: shared, right: shared })
|
||||
result.left.count = 2
|
||||
return [result === target, result.left === shared, result.left === result.right, shared.count]
|
||||
`),
|
||||
).toEqual([true, true, true, 2])
|
||||
})
|
||||
|
||||
test("Object.assign traverses shared aliases once", () => {
|
||||
const reads: Array<boolean> = []
|
||||
const shared = Object.defineProperty({}, "value", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return 1
|
||||
},
|
||||
})
|
||||
const target = {}
|
||||
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
|
||||
target,
|
||||
)
|
||||
expect(target).toEqual({ left: shared, right: shared })
|
||||
expect(reads).toEqual([true])
|
||||
})
|
||||
|
||||
test("assignment resolves and reads its left side before evaluating the right side", async () => {
|
||||
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
|
||||
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import { CodeMode, Namespace, Tool } from "../src/index.js"
|
||||
|
||||
const echo = (description: string, result: string) =>
|
||||
Tool.make({
|
||||
@@ -177,6 +177,48 @@ describe("blocked member names on tool paths", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("namespace metadata", () => {
|
||||
const tools = {
|
||||
api: Namespace.make({
|
||||
description: "Manage the workspace",
|
||||
tools: {
|
||||
users: Namespace.make({
|
||||
description: "Directory and account administration",
|
||||
tools: { list: echo("List users", "users") },
|
||||
}),
|
||||
status: echo("Read service status", "ok"),
|
||||
},
|
||||
}),
|
||||
plain: { read: echo("Read plain data", "plain") },
|
||||
}
|
||||
const runtime = CodeMode.make({ tools })
|
||||
|
||||
test("the wrapper does not add a segment to callable paths", async () => {
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
|
||||
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
|
||||
})
|
||||
|
||||
test("search matches descriptions from every enclosing namespace", async () => {
|
||||
const workspace = await value(runtime, `return search({ query: "workspace" })`)
|
||||
expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
|
||||
"tools.api.status",
|
||||
"tools.api.users.list",
|
||||
])
|
||||
|
||||
const directory = await value(runtime, `return search({ query: "account administration" })`)
|
||||
expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
|
||||
"tools.api.users.list",
|
||||
])
|
||||
})
|
||||
|
||||
test("a namespace description is optional", async () => {
|
||||
const optional = CodeMode.make({
|
||||
tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) },
|
||||
})
|
||||
expect(await value(optional, `return await tools.api.read({})`)).toBe("read")
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty segments", () => {
|
||||
test("tool names with empty segments are rejected at make", () => {
|
||||
for (const name of ["", "a..b", "trail.", ".lead"]) {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
export * as CodeModeCatalog from "./catalog.js"
|
||||
|
||||
import type { Namespace } from "@opencode-ai/schema/tool"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
export const Tool = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export type Inventory = {
|
||||
readonly tools: ReadonlyArray<Tool>
|
||||
readonly namespaces?: ReadonlyMap<string, Namespace>
|
||||
}
|
||||
|
||||
const Listing = Schema.Struct({
|
||||
path: Schema.String,
|
||||
line: Schema.String,
|
||||
})
|
||||
|
||||
const Namespace = Schema.Struct({
|
||||
const NamespaceSummary = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optionalKey(Schema.String),
|
||||
count: Schema.Number,
|
||||
entries: Schema.Array(Listing),
|
||||
})
|
||||
@@ -24,24 +31,30 @@ const Namespace = Schema.Struct({
|
||||
export const Summary = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
shown: Schema.Number,
|
||||
namespaces: Schema.Array(Namespace),
|
||||
namespaces: Schema.Array(NamespaceSummary),
|
||||
})
|
||||
export type Summary = typeof Summary.Type
|
||||
|
||||
export type Options = {
|
||||
readonly budget?: number
|
||||
}
|
||||
|
||||
const DESCRIPTION_LIMIT = 120
|
||||
const CHARACTERS_PER_TOKEN = 4
|
||||
const INLINE_BUDGET = 2_000
|
||||
|
||||
// Keep every namespace searchable, then select full listings one per namespace per round,
|
||||
// Keep every namespace visible, then select full listings one per namespace per round,
|
||||
// considering shorter listings first until the inline budget is exhausted.
|
||||
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
|
||||
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
|
||||
export function summarize(inventory: Inventory, options: Options = {}): Summary {
|
||||
const budget = options.budget ?? INLINE_BUDGET
|
||||
const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
|
||||
.sort(([left], [right]) => {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
})
|
||||
.map(([name, namespaceEntries]) => {
|
||||
const description = inventory.namespaces?.get(name)?.description
|
||||
const listings = namespaceEntries
|
||||
.map((entry) => {
|
||||
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
|
||||
@@ -64,6 +77,7 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
)
|
||||
return {
|
||||
name,
|
||||
...(description === undefined ? {} : { description }),
|
||||
listings,
|
||||
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
|
||||
selectedListings: pinned,
|
||||
@@ -72,11 +86,25 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
|
||||
let remaining =
|
||||
budget -
|
||||
namespaces.reduce(
|
||||
(total, namespace) =>
|
||||
total +
|
||||
cost(
|
||||
namespaceLine({
|
||||
name: namespace.name,
|
||||
...(namespace.description === undefined ? {} : { description: namespace.description }),
|
||||
count: namespace.listings.length,
|
||||
entries: [],
|
||||
}),
|
||||
),
|
||||
0,
|
||||
) -
|
||||
namespaces
|
||||
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
|
||||
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
|
||||
.reduce((total, listing) => total + cost(listing.line), 0)
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectionIndex]
|
||||
@@ -93,19 +121,31 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
|
||||
const namespaceSummaries = namespaces.map((namespace) => ({
|
||||
name: namespace.name,
|
||||
...(namespace.description === undefined ? {} : { description: namespace.description }),
|
||||
count: namespace.listings.length,
|
||||
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
|
||||
}))
|
||||
return {
|
||||
total: entries.length,
|
||||
total: inventory.tools.length,
|
||||
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
|
||||
namespaces: namespaceSummaries,
|
||||
}
|
||||
}
|
||||
|
||||
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
|
||||
}
|
||||
|
||||
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return listings
|
||||
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
|
||||
.map((listing) => ({ listing, cost: cost(listing.line) }))
|
||||
.toSorted((left, right) => {
|
||||
if (left.cost !== right.cost) return left.cost - right.cost
|
||||
if (left.listing.path < right.listing.path) return -1
|
||||
@@ -113,3 +153,7 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function cost(text: string) {
|
||||
return Math.round(text.length / CHARACTERS_PER_TOKEN)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,7 @@ export function render(catalog: CodeModeCatalog.Summary) {
|
||||
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
|
||||
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
|
||||
})
|
||||
|
||||
return `${prompt(catalog.shown < catalog.total)}
|
||||
@@ -47,6 +40,15 @@ ${render(current)}`
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return replacement
|
||||
|
||||
const descriptions = Instructions.diffByKey(
|
||||
previous.namespaces.filter((namespace) => namespace.description !== undefined),
|
||||
current.namespaces.filter((namespace) => namespace.description !== undefined),
|
||||
(namespace) => namespace.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
|
||||
return replacement
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
current.namespaces.flatMap((namespace) => namespace.entries),
|
||||
@@ -126,8 +128,8 @@ ${render(current)}`
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
|
||||
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
|
||||
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
|
||||
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
export * as CodeModeTool from "./tool.js"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
|
||||
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type {
|
||||
Content,
|
||||
Context,
|
||||
Error,
|
||||
Info,
|
||||
Metadata,
|
||||
Namespace as ToolNamespace,
|
||||
Result,
|
||||
} from "@opencode-ai/schema/tool"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -31,6 +40,21 @@ type CollectedFiles = {
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
type ToolNode = {
|
||||
tool?: Tool.Tool<never>
|
||||
namespace?: ToolNamespace
|
||||
readonly children: Map<string, ToolNode>
|
||||
}
|
||||
|
||||
type Tools = {
|
||||
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
|
||||
}
|
||||
|
||||
export type Inventory = {
|
||||
readonly tools: ReadonlyMap<string, Info>
|
||||
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
@@ -42,7 +66,7 @@ const description = [
|
||||
].join("\n")
|
||||
|
||||
export const create = (
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
|
||||
) => {
|
||||
return {
|
||||
@@ -61,7 +85,7 @@ export const create = (
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
inventory,
|
||||
(name, tool, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
@@ -132,36 +156,95 @@ export const create = (
|
||||
} satisfies Info
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
export const catalog = (inventory: Inventory) => {
|
||||
const pinned = new Set(
|
||||
Array.from(registrations.values())
|
||||
Array.from(inventory.tools.values())
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
|
||||
return {
|
||||
tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })),
|
||||
...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }),
|
||||
} satisfies CodeModeCatalog.Inventory
|
||||
}
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
// A path may carry namespace metadata, a callable tool, child tools, or all three.
|
||||
const root: ToolNode = { children: new Map() }
|
||||
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
|
||||
for (const [name, registration] of inventory.tools) {
|
||||
const child = definition(registration)
|
||||
const path = qualifiedName(registration)
|
||||
tools[path] = Tool.make({
|
||||
getNode(root, qualifiedName(registration)).tool = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema ?? Schema.NullOr(Schema.String),
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function getNode(root: ToolNode, path: string) {
|
||||
return path.split(".").reduce((parent, name) => {
|
||||
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
|
||||
parent.children.set(name, child)
|
||||
return child
|
||||
}, root)
|
||||
}
|
||||
|
||||
function renderTools(root: ToolNode) {
|
||||
const callables = new Map<string, Tool.Tool<never>>()
|
||||
const tools = renderChildren(root, [], callables)
|
||||
for (const [path, tool] of callables) tools[path] = tool
|
||||
return tools
|
||||
}
|
||||
|
||||
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
|
||||
return Object.fromEntries(
|
||||
Array.from(node.children).flatMap(([name, child]) => {
|
||||
const next = [...path, name]
|
||||
// A record cannot hold both a top-level tool and namespace under the same key.
|
||||
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
|
||||
const tools: Tools = {}
|
||||
flattenTools(child, next, tools)
|
||||
return Object.entries(tools)
|
||||
}
|
||||
return [[name, renderEntry(child, next, callables)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderEntry(
|
||||
node: ToolNode,
|
||||
path: ReadonlyArray<string>,
|
||||
callables: Map<string, Tool.Tool<never>>,
|
||||
): Tools[string] {
|
||||
const tools = renderChildren(node, path, callables)
|
||||
// CodeMode merges this dotted tool path with the nested namespace entry.
|
||||
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
|
||||
callables.set(path.join("."), node.tool)
|
||||
if (node.namespace !== undefined)
|
||||
return Namespace.make({
|
||||
description: node.namespace.description,
|
||||
tools,
|
||||
})
|
||||
if (node.tool === undefined) return tools
|
||||
if (node.children.size === 0) return node.tool
|
||||
return tools
|
||||
}
|
||||
|
||||
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
|
||||
if (node.tool !== undefined) tools[path.join(".")] = node.tool
|
||||
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = normalizedName(registration)
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
|
||||
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
const index = input.indexOf("/")
|
||||
return {
|
||||
providerID: Provider.ID.make(providerID),
|
||||
modelID: ID.make(modelID.join("/")),
|
||||
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
|
||||
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
assert: permission.assert,
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
get: (input) =>
|
||||
|
||||
@@ -96,7 +96,7 @@ const layer = Layer.effect(
|
||||
step = 1
|
||||
}
|
||||
if (pending?.type === "move")
|
||||
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
|
||||
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
|
||||
if (pending?.type === "compaction") {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
|
||||
+40
-20
@@ -1,6 +1,6 @@
|
||||
export * as Tool from "./tool.js"
|
||||
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
|
||||
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
|
||||
export type { Context, Metadata, Namespace, Options, Result } from "@opencode-ai/schema/tool"
|
||||
|
||||
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -26,6 +26,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
export interface Draft {
|
||||
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
|
||||
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
|
||||
readonly namespace: (namespace: Tool.Namespace) => void
|
||||
readonly add: (tool: Tool.Info) => void
|
||||
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
|
||||
readonly remove: (id: string) => void
|
||||
@@ -33,7 +34,8 @@ export interface Draft {
|
||||
|
||||
type Data = {
|
||||
tools: Map<string, Tool.Info & { readonly id: string }>
|
||||
errors: { tool: Tool.Info; error: RegistrationError }[]
|
||||
namespaces: Map<string, Tool.Namespace>
|
||||
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
@@ -42,7 +44,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export interface Snapshot {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
readonly codeModeCatalog?: CodeModeCatalog.Inventory
|
||||
readonly execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -151,15 +153,24 @@ const layer = Layer.effect(
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map(),
|
||||
namespaces: new Map(),
|
||||
errors: [],
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.tools.values()),
|
||||
get: (id) => draft.tools.get(id),
|
||||
namespace: (namespace) => {
|
||||
const error = namespaceError(namespace.name)
|
||||
if (error) {
|
||||
draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
|
||||
return
|
||||
}
|
||||
draft.namespaces.set(namespace.name, { ...namespace })
|
||||
},
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
|
||||
return
|
||||
}
|
||||
const id = effectiveName(tool)
|
||||
@@ -176,7 +187,7 @@ const layer = Layer.effect(
|
||||
tool.options = { ...tool.options, namespace: current.options?.namespace }
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(id, tool)
|
||||
@@ -188,10 +199,10 @@ const layer = Layer.effect(
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
({ kind, name, namespace, error }) =>
|
||||
Effect.logError(`Skipping invalid ${kind} registration`, {
|
||||
name,
|
||||
namespace,
|
||||
error: error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
@@ -210,23 +221,25 @@ const layer = Layer.effect(
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const codemodeEnabled = !whollyDisabled("execute", rules)
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) =>
|
||||
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const namespaces = state.get().namespaces
|
||||
const codeModeInventory = { tools: codeModeTools, namespaces }
|
||||
const codeModeEnabled = !whollyDisabled("execute", rules)
|
||||
const codeModeTool = codeModeEnabled
|
||||
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
|
||||
beforeExecute(name, input, context).pipe(
|
||||
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
...(codeModeTool ? [definition(codeModeTool)] : []),
|
||||
],
|
||||
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
|
||||
const context: Tool.Context = {
|
||||
@@ -239,11 +252,11 @@ const layer = Layer.effect(
|
||||
const event = yield* beforeExecute(input.call.name, input.call.input, context)
|
||||
const requested = input.definitions?.get(event.tool)
|
||||
// Preserve session context removal and alias resolution, now after the repair hook.
|
||||
if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool))
|
||||
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
|
||||
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
|
||||
const name = requested?.name ?? event.tool
|
||||
if (name === "execute" && codemodeTool)
|
||||
return yield* executeTool(codemodeTool, name, event.input, context)
|
||||
if (name === "execute" && codeModeTool)
|
||||
return yield* executeTool(codeModeTool, name, event.input, context)
|
||||
const tool = direct.get(name)
|
||||
if (tool) return yield* executeTool(tool, name, event.input, context)
|
||||
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
|
||||
@@ -269,8 +282,10 @@ function schemaMakeError(error: unknown) {
|
||||
|
||||
function registrationError(tool: Tool.Info) {
|
||||
const namespace = tool.options?.namespace
|
||||
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
|
||||
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
|
||||
if (namespace !== undefined) {
|
||||
const error = namespaceError(namespace)
|
||||
if (error) return error
|
||||
}
|
||||
const name = normalizedName(tool)
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
const id = effectiveName(tool)
|
||||
@@ -284,6 +299,11 @@ function registrationError(tool: Tool.Info) {
|
||||
return Result.isFailure(result) ? result.failure : undefined
|
||||
}
|
||||
|
||||
function namespaceError(name: string) {
|
||||
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return
|
||||
return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` })
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -32,6 +32,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
|
||||
|
||||
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
|
||||
|
||||
- The latest valid active registration for the same effective name wins.
|
||||
|
||||
@@ -23,14 +23,17 @@ describe("CodeMode", () => {
|
||||
|
||||
const snapshot = yield* tools.snapshot()
|
||||
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
|
||||
expect(snapshot.codeModeCatalog).toStrictEqual([
|
||||
{
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
expect(snapshot.codeModeCatalog).toStrictEqual({
|
||||
tools: [
|
||||
{
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
pinned: true,
|
||||
},
|
||||
],
|
||||
namespaces: new Map(),
|
||||
})
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
|
||||
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
@@ -15,21 +15,24 @@ const lookup = entry(
|
||||
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
)
|
||||
|
||||
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
|
||||
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
|
||||
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
|
||||
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
|
||||
|
||||
const update = (
|
||||
previous: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
current: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
previous: ReadonlyArray<CodeModeCatalog.Tool>,
|
||||
current: ReadonlyArray<CodeModeCatalog.Tool>,
|
||||
budget?: number,
|
||||
) =>
|
||||
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
|
||||
CodeModeInstructions.update(
|
||||
CodeModeCatalog.summarize({ tools: previous }, budget === undefined ? {} : { budget }),
|
||||
CodeModeCatalog.summarize({ tools: current }, budget === undefined ? {} : { budget }),
|
||||
)
|
||||
|
||||
describe("CodeModeCatalog.summarize", () => {
|
||||
test("retains namespace inventory without retaining tools outside the inline budget", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
|
||||
0,
|
||||
{ tools: Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) },
|
||||
{ budget: 0 },
|
||||
)
|
||||
expect(catalog).toEqual({
|
||||
total: 10_000,
|
||||
@@ -40,8 +43,8 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
|
||||
test("retains every namespace when no full tool listing fits", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
|
||||
0,
|
||||
{ tools: [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")] },
|
||||
{ budget: 0 },
|
||||
)
|
||||
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
|
||||
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
|
||||
@@ -49,7 +52,10 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
|
||||
test("always retains pinned tools beyond the inline budget", () => {
|
||||
const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)]
|
||||
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
{ tools: [...pinned, entry("alpha.unpinned", "Unpinned")] },
|
||||
{ budget: 0 },
|
||||
)
|
||||
|
||||
expect(catalog.shown).toBe(2)
|
||||
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
|
||||
@@ -63,22 +69,48 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
const unpinned = entry("beta.unpinned", "Unpinned")
|
||||
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
|
||||
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
|
||||
const namespaceCost = [
|
||||
{ name: "alpha", count: 1, entries: [] },
|
||||
{ name: "beta", count: 1, entries: [] },
|
||||
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
|
||||
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
|
||||
expect(
|
||||
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost })
|
||||
.shown,
|
||||
).toBe(2)
|
||||
expect(
|
||||
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost - 1 })
|
||||
.shown,
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test("retains only the rendered portion of inline descriptions", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
|
||||
const catalog = CodeModeCatalog.summarize({
|
||||
tools: [entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)],
|
||||
})
|
||||
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
|
||||
})
|
||||
|
||||
test("limits inline descriptions to 120 characters", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
|
||||
const catalog = CodeModeCatalog.summarize({ tools: [entry("alpha.one", "x".repeat(121))] })
|
||||
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
|
||||
expect(description).toHaveLength(120)
|
||||
expect(description).toEndWith("...")
|
||||
})
|
||||
|
||||
test("always retains namespace descriptions and charges them before tool listings", () => {
|
||||
const tool = entry("alpha.one", "One")
|
||||
const listingCost = Math.round(` - ${tool.signature} // One`.length / 4)
|
||||
const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4)
|
||||
const description = "A namespace description that stays visible beyond the available tool budget"
|
||||
const namespaces = new Map([["alpha", { name: "alpha", description }]])
|
||||
|
||||
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
|
||||
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
|
||||
expect(catalog.shown).toBe(0)
|
||||
expect(catalog.namespaces[0]?.description).toBe(description)
|
||||
expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeModeInstructions.render", () => {
|
||||
@@ -104,7 +136,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
)
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
|
||||
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
})
|
||||
|
||||
@@ -118,7 +151,11 @@ describe("CodeModeInstructions.render", () => {
|
||||
)
|
||||
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
|
||||
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
|
||||
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
|
||||
const namespaceCost = [
|
||||
{ name: "alpha", count: 2, entries: [] },
|
||||
{ name: "beta", count: 1, entries: [] },
|
||||
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
|
||||
const instructions = render([cheapAlpha, expensive, cheapBeta], 40 + namespaceCost)
|
||||
expect(instructions).toContain("## Search")
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
|
||||
@@ -170,6 +207,21 @@ describe("CodeModeInstructions.update", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("restates namespace descriptions when they change", () => {
|
||||
const previous = CodeModeCatalog.summarize({
|
||||
tools: [echo],
|
||||
namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]),
|
||||
})
|
||||
const current = CodeModeCatalog.summarize({
|
||||
tools: [echo],
|
||||
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
|
||||
})
|
||||
const text = CodeModeInstructions.update(previous, current)
|
||||
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
|
||||
expect(text).toContain("- notes (1 tool) // New description")
|
||||
expect(text).not.toContain("Old description")
|
||||
})
|
||||
|
||||
test("restates the full catalog when the rendering mode crosses full and compact", () => {
|
||||
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
|
||||
const text = update([echo], [echo, ...wide], 30)
|
||||
|
||||
@@ -9,13 +9,13 @@ import { Effect, Schema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const echo: CodeModeCatalog.Entry = {
|
||||
const echo: CodeModeCatalog.Tool = {
|
||||
path: "notes.echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
}
|
||||
|
||||
const lookup: CodeModeCatalog.Entry = {
|
||||
const lookup: CodeModeCatalog.Tool = {
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order",
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
|
||||
@@ -24,16 +24,16 @@ const lookup: CodeModeCatalog.Entry = {
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("instructs the model not to call execute while the catalog is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([]))
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [] }))
|
||||
expect(initialized.text).toBe(
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
)
|
||||
|
||||
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
|
||||
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), initialized)
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(echo.signature)
|
||||
|
||||
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
|
||||
expect(yield* readUpdate(CodeModeInstructions.make({ tools: [] }), { values: added.values })).toMatchObject({
|
||||
text:
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
@@ -43,7 +43,7 @@ describe("CodeModeInstructions", () => {
|
||||
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
|
||||
expect(initialized.text).toContain(
|
||||
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
|
||||
)
|
||||
@@ -51,13 +51,13 @@ describe("CodeModeInstructions", () => {
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
|
||||
|
||||
const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized)
|
||||
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo, lookup] }), initialized)
|
||||
expect(added.text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
|
||||
expect(added.text).not.toContain("## Available tools")
|
||||
|
||||
const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values })
|
||||
const removed = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), { values: added.values })
|
||||
expect(removed.text).toBe(
|
||||
"The Code Mode tool catalog has changed.\n\n" +
|
||||
"The following tools are no longer available and must not be called: tools.orders.lookup.",
|
||||
@@ -93,22 +93,27 @@ describe("CodeModeInstructions", () => {
|
||||
const initialized = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* tools.transform((draft) => {
|
||||
draft.namespace({ name: "tools", description: "Project utilities" })
|
||||
draft.add({ ...zeta, options: { namespace: "tools" } })
|
||||
draft.add({ ...alpha, options: { namespace: "tools" } })
|
||||
})
|
||||
return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog))
|
||||
const snapshot = yield* tools.snapshot()
|
||||
return yield* readInitial(CodeModeInstructions.make(snapshot.codeModeCatalog))
|
||||
}),
|
||||
)
|
||||
const reordered = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* tools.transform((draft) => {
|
||||
draft.namespace({ name: "tools", description: "Project utilities" })
|
||||
draft.add({ ...alpha, options: { namespace: "tools" } })
|
||||
draft.add({ ...zeta, options: { namespace: "tools" } })
|
||||
})
|
||||
return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized)
|
||||
const snapshot = yield* tools.snapshot()
|
||||
return yield* readUpdate(CodeModeInstructions.make(snapshot.codeModeCatalog), initialized)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(initialized.text).toContain("- tools (2 tools) // Project utilities")
|
||||
expect(reordered.changed).toBe(false)
|
||||
expect(reordered.text).toBe("")
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -147,6 +147,20 @@ describe("cross-spawn spawner", () => {
|
||||
})
|
||||
|
||||
describe("stderr", () => {
|
||||
fx.live(
|
||||
"captures both streams across backpressure",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
'process.stdout.write("o".repeat(256 * 1024)); process.stderr.write("e".repeat(256 * 1024))',
|
||||
)
|
||||
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
expect(output).toEqual(["o".repeat(256 * 1024), "e".repeat(256 * 1024)])
|
||||
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures stderr output",
|
||||
Effect.gen(function* () {
|
||||
@@ -180,37 +194,6 @@ describe("cross-spawn spawner", () => {
|
||||
})
|
||||
|
||||
describe("combined output (all)", () => {
|
||||
for (const output of ["stdout", "stderr", "all"] as const) {
|
||||
fx.live(
|
||||
`captures ${output} when reading starts after process exit`,
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
|
||||
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
// Let exit callbacks finish before attaching a reader; the handle scope remains open.
|
||||
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)))
|
||||
expect((yield* decodeByteStream(handle[output])).split("\n").toSorted()).toEqual(
|
||||
output === "all" ? ["stderr", "stdout"] : [output],
|
||||
)
|
||||
}).pipe(Effect.timeout("3 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
fx.live(
|
||||
"drains output larger than the capture buffers",
|
||||
Effect.gen(function* () {
|
||||
const text = "x".repeat(1024 * 1024)
|
||||
const handle = yield* js(
|
||||
`const text = "x".repeat(${text.length}); process.stdout.write(text); process.stderr.write(text)`,
|
||||
)
|
||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
expect(stdout).toBe(text)
|
||||
expect(stderr).toBe(text)
|
||||
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}).pipe(Effect.timeout("3 seconds")),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures stdout via .all when no stderr",
|
||||
Effect.gen(function* () {
|
||||
@@ -230,6 +213,30 @@ describe("cross-spawn spawner", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("delayed output consumption", () => {
|
||||
for (const combined of [false, true]) {
|
||||
fx.live(
|
||||
`retains ${combined ? "combined" : "separate"} output after process completion`,
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
'require("node:fs").writeSync(1, "stdout\\n"); require("node:fs").writeSync(2, "stderr\\n")',
|
||||
)
|
||||
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
if (combined) {
|
||||
const output = yield* decodeByteStream(handle.all)
|
||||
expect(output).toContain("stdout")
|
||||
expect(output).toContain("stderr")
|
||||
return
|
||||
}
|
||||
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
expect(output).toEqual(["stdout", "stderr"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("stdin", () => {
|
||||
fx.effect(
|
||||
"allows providing standard input to a command",
|
||||
@@ -248,63 +255,6 @@ describe("cross-spawn spawner", () => {
|
||||
})
|
||||
|
||||
describe("process control", () => {
|
||||
fx.live(
|
||||
"reports exit without waiting for unread stdout",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("process.stdout.write(Buffer.alloc(1024 * 1024)); process.exit(0)")
|
||||
expect(yield* Effect.promise(() => gone(Number(handle.pid)))).toBe(true)
|
||||
expect(yield* handle.exitCode.pipe(Effect.timeout("500 millis"))).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
expect(yield* handle.isRunning).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.live(
|
||||
"releases a process with unread buffered stdout",
|
||||
Effect.gen(function* () {
|
||||
const pid = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
'process.stdout.write("x".repeat(1024 * 1024)); process.stderr.write("ready"); setInterval(() => {}, 10_000)',
|
||||
{ forceKillAfter: 100 },
|
||||
)
|
||||
expect(yield* decodeByteStream(handle.stderr.pipe(Stream.take(1)))).toBe("ready")
|
||||
return Number(handle.pid)
|
||||
}),
|
||||
)
|
||||
expect(yield* Effect.promise(() => gone(pid))).toBe(true)
|
||||
}).pipe(Effect.timeout("3 seconds")),
|
||||
)
|
||||
|
||||
// Node puts non-detached Windows children in a kill-on-parent-exit job; this guards POSIX group cleanup.
|
||||
const groupTest = process.platform === "win32" ? fx.live.skip : fx.live
|
||||
groupTest(
|
||||
"preserves successful descendants when an exit-only scope closes",
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const pidFile = path.join(tmp.path, "child.pid")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
// This fixture's child shares the process group and holds stdio after the parent exits on stdin EOF.
|
||||
const handle = yield* ChildProcess.make(
|
||||
"node",
|
||||
[path.join(import.meta.dir, "../fixture/held-stdio.cjs"), "mcp", pidFile],
|
||||
{ stdin: "ignore", forceKillAfter: 100 },
|
||||
)
|
||||
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true)
|
||||
}).pipe(Effect.timeout("3 seconds")),
|
||||
)
|
||||
|
||||
for (const mode of ["exit", "SIGKILL"] as const) {
|
||||
const test = mode === "SIGKILL" && process.platform === "win32" ? fx.live.skip : fx.live
|
||||
test(
|
||||
|
||||
@@ -35,7 +35,7 @@ export function waitForCodeModeTool(
|
||||
): Effect.Effect<Tool.Snapshot, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const toolSet = yield* registry.snapshot()
|
||||
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
|
||||
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
|
||||
if (remaining === 0) {
|
||||
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
|
||||
}
|
||||
|
||||
@@ -1764,7 +1764,9 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
"direct_media",
|
||||
"execute",
|
||||
])
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
|
||||
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
|
||||
"ok: boolean",
|
||||
)
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
}),
|
||||
)
|
||||
@@ -1782,7 +1784,9 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
|
||||
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
|
||||
"properties.sessionID",
|
||||
)
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
|
||||
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
|
||||
"sessionID",
|
||||
)
|
||||
|
||||
const directSessionID = Session.ID.make("ses_mcp_direct")
|
||||
yield* toolSet.execute({
|
||||
@@ -1826,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_content_only"),
|
||||
@@ -1912,7 +1916,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const fiber = yield* toolSet
|
||||
.execute({
|
||||
@@ -1956,7 +1960,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_blocked"),
|
||||
|
||||
@@ -5,6 +5,23 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Model.Ref)
|
||||
|
||||
describe("Model.parse", () => {
|
||||
test.each([
|
||||
["vendor/model", "vendor", "model"],
|
||||
["vendor/team/model", "vendor", "team/model"],
|
||||
["vendor", "vendor", ""],
|
||||
["", "", ""],
|
||||
["/model", "", "model"],
|
||||
["vendor/", "vendor", ""],
|
||||
["vendor//model/", "vendor", "/model/"],
|
||||
])("parses %j at the first slash", (input, providerID, modelID) => {
|
||||
expect(Model.parse(input)).toEqual({
|
||||
providerID: Provider.ID.make(providerID),
|
||||
modelID: Model.ID.make(modelID),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model.Ref", () => {
|
||||
test("accepts a model selection without a variant", () => {
|
||||
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
|
||||
|
||||
@@ -248,6 +248,16 @@ describe("Patch", () => {
|
||||
).toBe("line 1\nline 2\nadded 1\nadded 2\n")
|
||||
})
|
||||
|
||||
test.each(["", "original\n"])("preserves equal-offset insertion order and frozen chunks for %j", (original) => {
|
||||
const chunks = Object.freeze([
|
||||
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["first"]) }),
|
||||
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["second", "third"]) }),
|
||||
])
|
||||
const expected = { content: original + "first\nsecond\nthird\n", bom: false }
|
||||
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
|
||||
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("applies a pure-addition chunk after an earlier replacement", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
|
||||
@@ -104,6 +104,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
assert: () => Effect.die("unused permission.assert"),
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), {
|
||||
replacements: [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Queue.unbounded<void>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const ready = yield* Deferred.make<Context>()
|
||||
yield* plugins.activate([{ id: "permission-test", version: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
|
||||
const ctx = yield* Deferred.await(ready)
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update("permission-test", (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "deploy", resource: "*", effect: "ask" },
|
||||
{ action: "deploy", resource: "allowed", effect: "allow" },
|
||||
{ action: "deploy", resource: "blocked", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const sessionID = Session.ID.create()
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: location.project.id,
|
||||
slug: "permission-test",
|
||||
directory: location.directory,
|
||||
title: "Permission test",
|
||||
version: "test",
|
||||
agent: "missing",
|
||||
})
|
||||
.run()
|
||||
const input = {
|
||||
id: Permission.ID.create(),
|
||||
sessionID,
|
||||
agent: Agent.ID.make("permission-test"),
|
||||
action: "deploy",
|
||||
resources: ["staging"],
|
||||
save: ["staging"],
|
||||
metadata: { environment: "staging" },
|
||||
source: { type: "tool", messageID: "msg_test", id: "call_test" },
|
||||
} satisfies Permission.AssertInput
|
||||
return { ctx, input, asked }
|
||||
})
|
||||
|
||||
describe("plugin permission.assert", () => {
|
||||
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
|
||||
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
|
||||
yield* Queue.take(asked)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
})
|
||||
if (reply === "cancel") yield* Fiber.interrupt(fiber)
|
||||
if (reply !== "cancel")
|
||||
yield* ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
message: reply === "feedback" ? "Use the test environment" : undefined,
|
||||
})
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
|
||||
if (reply !== "once") {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
if (reply === "reject")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
|
||||
)
|
||||
if (reply === "feedback")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_tag: "Fail",
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-permission-test",
|
||||
setup: async (ctx) => {
|
||||
await expect(
|
||||
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
|
||||
).rejects.toBeDefined()
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
|
||||
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
|
||||
for (const reply of ["once", "reject", "feedback"] as const) {
|
||||
const pending = ctx.permission.assert(input)
|
||||
const settled = pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
await Effect.runPromise(Queue.take(asked))
|
||||
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
save: input.save,
|
||||
})
|
||||
await ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
|
||||
})
|
||||
if (reply === "once") expect(await settled).toEqual({ value: undefined })
|
||||
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
|
||||
if (reply === "feedback")
|
||||
expect(await settled).toEqual({
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
})
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
).effect(ctx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -963,7 +963,7 @@ describe("fromPromise", () => {
|
||||
})
|
||||
const original = yield* registry.snapshot()
|
||||
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
|
||||
expect(original.codeModeCatalog).toEqual([])
|
||||
expect(original.codeModeCatalog?.tools).toEqual([])
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
@@ -981,7 +981,7 @@ describe("fromPromise", () => {
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["acme.hello"])
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
|
||||
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
|
||||
expect(
|
||||
yield* snapshot.execute({
|
||||
|
||||
@@ -115,13 +115,15 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(Tool.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
codeModeCatalog: [
|
||||
{
|
||||
path: "captured.lookup",
|
||||
description: "Captured Code Mode catalog",
|
||||
signature: "tools.captured.lookup(input: {}): Promise<string>",
|
||||
},
|
||||
],
|
||||
codeModeCatalog: {
|
||||
tools: [
|
||||
{
|
||||
path: "captured.lookup",
|
||||
description: "Captured Code Mode catalog",
|
||||
signature: "tools.captured.lookup(input: {}): Promise<string>",
|
||||
},
|
||||
],
|
||||
},
|
||||
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
execute: () => Effect.die(new Error("unused")),
|
||||
}),
|
||||
|
||||
@@ -1454,32 +1454,49 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
|
||||
})
|
||||
|
||||
scenario("preserves a tool continuation across a steered move", function* (s) {
|
||||
yield* s.admit("Echo before moving")
|
||||
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
scenario(`preserves a tool continuation and step allowance across chained moves (${delivery})`, function* (s) {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
yield* s.admit("Echo before moving")
|
||||
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* Effect.forEach(["steer", delivery] as const, (delivery) =>
|
||||
s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(messageRoles(s.requests[1])?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
|
||||
expect(s.requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(s.requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(
|
||||
(yield* recordedEventTypes(sessionID)).filter(
|
||||
(type) => type === "session.step.started.1" || type === "session.moved.1",
|
||||
),
|
||||
).toEqual(["session.step.started.1", "session.moved.1", "session.moved.1", "session.step.started.1"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(s.requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
scenario("keeps queued input parked across a mid-turn move", function* (s) {
|
||||
yield* s.admit("Echo before moving")
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -273,45 +272,6 @@ describe("Session.shell", () => {
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("keeps success when the invocation timeout expires during post-exit capture", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const pidFile = path.join(fixture.tmp.path, "child.pid")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const info = yield* fixture.shell.create({
|
||||
command: `node "${path.join(import.meta.dir, "fixture/held-stdio.cjs")}" exit "${pidFile}"`,
|
||||
timeout: 500,
|
||||
})
|
||||
const completion = yield* fixture.shell.wait(info.id).pipe(Effect.forkScoped)
|
||||
// Wait for the real process without advancing its invocation timeout or capture deadline.
|
||||
yield* fixture.shell
|
||||
.get(info.id)
|
||||
.pipe(
|
||||
Effect.repeat({ until: (info) => info.status === "exited", schedule: Schedule.spaced("10 millis") }),
|
||||
Effect.timeout("3 seconds"),
|
||||
TestClock.withLive,
|
||||
)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(yield* fixture.shell.get(info.id)).toMatchObject({ status: "exited", exit: 0 })
|
||||
expect(completion.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(yield* Fiber.join(completion).pipe(Effect.timeout("3 seconds"), TestClock.withLive)).toMatchObject({
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
})
|
||||
const result = yield* fixture.shell.result(info)
|
||||
expect(result.capture?.output).toContain("foreground-out")
|
||||
expect(result.capture?.output).toContain("foreground-err")
|
||||
const pid = Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8")))
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
for (const outcome of [
|
||||
{
|
||||
status: "killed",
|
||||
|
||||
@@ -17,7 +17,7 @@ const context = {
|
||||
}
|
||||
|
||||
const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
|
||||
CodeModeTool.create(tools, (_, tool, input, context) => execute(tool, input, context))
|
||||
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context))
|
||||
|
||||
test("execute describes invariant Code Mode behavior", () => {
|
||||
expect(createCodeMode(new Map()).description).toBe(
|
||||
|
||||
@@ -241,6 +241,22 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces a file with a directory containing an added file", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "parent"), "before\n"))
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Delete File: parent\n*** Add File: parent/child.txt\n+after\n*** End Patch"),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "parent/child.txt"), "utf8"))).toBe(
|
||||
"after\n",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("counts deleted lines with and without a trailing newline", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("Tool", () => {
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
|
||||
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
|
||||
expect((yield* service.snapshot()).codeModeCatalog?.tools).toEqual([])
|
||||
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
|
||||
|
||||
text = "refreshed"
|
||||
@@ -239,7 +239,7 @@ describe("Tool", () => {
|
||||
yield* Fiber.join(reload)
|
||||
const refreshed = yield* service.snapshot()
|
||||
expect(refreshed.definitions[0]?.description).toBe("Updated")
|
||||
expect(refreshed.codeModeCatalog).toEqual([])
|
||||
expect(refreshed.codeModeCatalog?.tools).toEqual([])
|
||||
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
|
||||
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
|
||||
|
||||
@@ -247,7 +247,7 @@ describe("Tool", () => {
|
||||
yield* update.dispose
|
||||
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
|
||||
expect((yield* service.snapshot()).codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["hidden"])
|
||||
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("acme_echo", (tool) => {
|
||||
@@ -440,7 +440,7 @@ describe("Tool", () => {
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
expect(snapshot.codeModeCatalog?.tools).toEqual([])
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Tool", () => {
|
||||
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
|
||||
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
|
||||
expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
expect(snapshot.codeModeCatalog?.tools).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -502,7 +502,7 @@ describe("Tool", () => {
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
|
||||
"-lookup",
|
||||
"123",
|
||||
"123._private.-tools.2d_get_scene",
|
||||
@@ -534,6 +534,7 @@ describe("Tool", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.namespace({ name: "invalid..namespace", description: "Invalid" })
|
||||
draft.add({ ...make(), name: "first", options: { codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
|
||||
@@ -541,7 +542,95 @@ describe("Tool", () => {
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps namespace descriptions beside catalog tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.namespace({ name: "registry", description: "Package publishing and discovery" })
|
||||
draft.namespace({ name: "registry.search", description: "Pricing operations" })
|
||||
draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } })
|
||||
draft.add({ ...make(), name: "direct", options: { namespace: "registry", codemode: false } })
|
||||
draft.add({ ...make(), name: "search", description: "Search packages", options: { namespace: "registry" } })
|
||||
draft.add({ ...make(), name: "sales", description: "Read sales", options: { namespace: "registry.search" } })
|
||||
})
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
|
||||
"legacy.plain",
|
||||
"registry.search",
|
||||
"registry.search.sales",
|
||||
])
|
||||
expect(snapshot.codeModeCatalog?.namespaces).toEqual(
|
||||
new Map([
|
||||
["registry", { name: "registry", description: "Package publishing and discovery" }],
|
||||
["registry.search", { name: "registry.search", description: "Pricing operations" }],
|
||||
]),
|
||||
)
|
||||
const result = yield* snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "namespace-search",
|
||||
name: "execute",
|
||||
input: { code: 'return search({ query: "pricing operations" })' },
|
||||
},
|
||||
})
|
||||
expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") })
|
||||
const callable = yield* snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "callable-namespace",
|
||||
name: "execute",
|
||||
input: {
|
||||
code: `return await Promise.all([
|
||||
tools.registry.search({ text: "search" }),
|
||||
tools.registry.search.sales({ text: "sales" }),
|
||||
])`,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(callable.output).toMatchObject({
|
||||
output: expect.stringContaining('"text": "sales"'),
|
||||
toolCalls: [
|
||||
{ tool: "registry.search", status: "completed" },
|
||||
{ tool: "registry.search.sales", status: "completed" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a top-level tool that also has child tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.namespace({ name: "pricing", description: "Pricing operations" })
|
||||
draft.add({ ...make(), name: "pricing" })
|
||||
draft.add({ ...make(), name: "sales", options: { namespace: "pricing" } })
|
||||
})
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"])
|
||||
const result = yield* snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "top-level-callable",
|
||||
name: "execute",
|
||||
input: {
|
||||
code: `return await Promise.all([
|
||||
tools.pricing({ text: "pricing" }),
|
||||
tools.pricing.sales({ text: "sales" }),
|
||||
])`,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.output).toMatchObject({ output: expect.stringContaining('"text": "sales"') })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -575,7 +664,7 @@ describe("Tool", () => {
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
@@ -640,7 +729,7 @@ describe("Tool", () => {
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
|
||||
expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -650,7 +739,7 @@ describe("Tool", () => {
|
||||
|
||||
const available = yield* service.snapshot()
|
||||
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(available.codeModeCatalog).toEqual([])
|
||||
expect(available.codeModeCatalog?.tools).toEqual([])
|
||||
|
||||
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
expect(denied.definitions).toEqual([])
|
||||
@@ -1103,7 +1192,7 @@ describe("Tool", () => {
|
||||
}).pipe(Scope.provide(scope))
|
||||
const toolSet = yield* service.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
|
||||
expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
|
||||
expect(execute?.description).toContain("confined Code Mode runtime")
|
||||
expect(execute?.description).not.toContain("Echo text")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/effect/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { Effect } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface PermissionEvaluation {
|
||||
@@ -20,5 +21,6 @@ export interface PermissionHooks {
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
readonly assert: (input: PermissionCreateInput) => Effect.Effect<void, unknown>
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Hooks, Transform } from "./registration.js"
|
||||
export interface ToolDraft {
|
||||
list(): readonly (Tool.Info & { readonly id: string })[]
|
||||
get(id: string): (Tool.Info & { readonly id: string }) | undefined
|
||||
namespace(namespace: Tool.Namespace): void
|
||||
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
|
||||
tool: Tool.Info<Input, Output>,
|
||||
): void
|
||||
|
||||
@@ -259,13 +259,14 @@ export function fromPromise(plugin: Plugin) {
|
||||
const adaptApiMethod = <PromiseMethod>(
|
||||
endpoint: HttpApiEndpoint.Top,
|
||||
method: (input: never) => Effect.Effect<unknown, unknown>,
|
||||
options?: { readonly noContent?: boolean },
|
||||
) => {
|
||||
const compiled = compileEndpoint(endpoint)
|
||||
return ((input?: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
|
||||
const result = yield* method(Object.assign({}, ...decoded) as never)
|
||||
if (compiled.noContent) return undefined
|
||||
if (compiled.noContent || options?.noContent) return undefined
|
||||
return yield* compiled.encode(result)
|
||||
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
|
||||
}
|
||||
@@ -428,6 +429,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
permission: {
|
||||
assert: adaptApiMethod(PermissionEndpoints["session.permission.create"], host.permission.assert, {
|
||||
noContent: true,
|
||||
}),
|
||||
hook: (name, callback) =>
|
||||
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
|
||||
@@ -465,6 +469,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
const tool = draft.get(id)
|
||||
return tool ? { ...tool, execute: promiseExecutor(tool.execute) } : undefined
|
||||
},
|
||||
namespace: draft.namespace,
|
||||
add: (tool: Info) =>
|
||||
draft.add({
|
||||
...tool,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/promise/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -20,5 +20,6 @@ export interface PermissionHooks {
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
readonly assert: (input: PermissionCreateInput) => Promise<void>
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type Info<
|
||||
interface ToolDraft {
|
||||
list(): readonly (Info & { readonly id: string })[]
|
||||
get(id: string): (Info & { readonly id: string }) | undefined
|
||||
namespace(namespace: Tool.Namespace): void
|
||||
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
|
||||
tool: Info<Input, Output>,
|
||||
): void
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface Context {
|
||||
readonly progress: (update: Metadata) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Namespace {
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
interface BaseOptions {
|
||||
readonly namespace?: string
|
||||
readonly permission?: string
|
||||
|
||||
@@ -153,6 +153,19 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
})
|
||||
|
||||
story("keeps inline code backgrounds 18px tall", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({ text: "`value` and `src/file.ts`" })
|
||||
}, fixture)
|
||||
const code = page.getByTestId("markdown-fixture").locator(":not(pre) > code")
|
||||
await expect(code).toHaveCount(2)
|
||||
await expect(code.nth(1)).toHaveAttribute("data-inline-code-kind", "path")
|
||||
expect(
|
||||
await code.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height)),
|
||||
).toEqual([18, 18])
|
||||
})
|
||||
|
||||
story("shares in-flight Markdown rendering without overwriting a reclaimed cache entry", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { getCachedMarkdown, renderCachedMarkdown } = await import(fixture)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { readLocalImage } from "../../app/src/runtime/server/image"
|
||||
import { MarkdownProvider } from "../src/context/markdown"
|
||||
import { CurrentSessionProviders } from "../src/storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../src/storybook/current-session-scenarios"
|
||||
import { CurrentContextToolGroup, ToolDisplay } from "../src/tools/tool-renderer"
|
||||
|
||||
export function mountReadImage(options: { path: string; grouped: boolean; running?: boolean }) {
|
||||
const host = document.createElement("div")
|
||||
host.dataset.testid = "read-image-fixture"
|
||||
document.body.appendChild(host)
|
||||
render(() => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: location.origin,
|
||||
headers: { Authorization: `Basic ${btoa("opencode:fixture")}` },
|
||||
})
|
||||
const [state, setState] = createStore({ open: true, visible: true, running: !!options.running, appended: false })
|
||||
const status = () => (state.running ? "running" : "completed")
|
||||
const tools = createMemo(() => [
|
||||
storyTool("read_image", "read", status(), { path: options.path }),
|
||||
storyTool("read_text", "read", "completed", { path: "src/example.ts", limit: 20 }),
|
||||
...(state.appended ? [storyTool("read_next", "read", "completed", { path: "src/next.ts" })] : []),
|
||||
])
|
||||
return (
|
||||
<section style={{ "max-width": "720px", padding: "24px" }}>
|
||||
<button onClick={() => setState("running", false)}>Finish read</button>
|
||||
<button onClick={() => setState("appended", true)}>Append read</button>
|
||||
<button onClick={() => setState("visible", false)}>Unmount tools</button>
|
||||
<MarkdownProvider readImage={(path, signal) => readLocalImage(api, "C:/project", path, signal)}>
|
||||
<CurrentSessionProviders document={storyDocument(tools())}>
|
||||
<Show when={state.visible}>
|
||||
<Show
|
||||
when={options.grouped}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id="read_image"
|
||||
tool="read"
|
||||
input={{ path: options.path }}
|
||||
metadata={{}}
|
||||
status={status()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentContextToolGroup
|
||||
parts={tools()}
|
||||
busy={state.running}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</MarkdownProvider>
|
||||
</section>
|
||||
)
|
||||
}, host)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./read-image.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
story(
|
||||
`lazily previews ${grouped ? "grouped" : "standalone"} image reads and releases them on collapse`,
|
||||
async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/fs/read/**", async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
`Basic ${Buffer.from("opencode:fixture").toString("base64")}`,
|
||||
)
|
||||
requests.push(route.request().url())
|
||||
await route.fulfill({ contentType: "image/png", body: png })
|
||||
})
|
||||
await page.evaluate(
|
||||
async ({ fixture, grouped }) => {
|
||||
const { mountReadImage } = await import(fixture)
|
||||
mountReadImage({ path: "C:\\tmp\\chart%20 one.PNG", grouped, running: true })
|
||||
},
|
||||
{ fixture, grouped },
|
||||
)
|
||||
const root = page.getByTestId("read-image-fixture")
|
||||
const trigger = root.getByRole("button", { name: "Read chart%20 one.PNG", exact: true })
|
||||
const image = root.getByRole("img", { name: "chart%20 one.PNG", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await root.getByRole("button", { name: "Finish read", exact: true }).click()
|
||||
await expect(trigger.locator('[data-slot="collapsible-arrow"]')).toBeVisible()
|
||||
expect(requests).toEqual([])
|
||||
await expect(image).toHaveCount(0)
|
||||
if (grouped) {
|
||||
const text = root.locator('[data-slot="context-tool-group-item"]').filter({ hasText: "example.ts" })
|
||||
await expect(text).toContainText("limit=20")
|
||||
await expect(text.getByRole("button")).toHaveCount(0)
|
||||
}
|
||||
for (const action of ["click", "Enter", "Space"]) {
|
||||
if (action === "click") await trigger.click()
|
||||
if (action !== "click") await trigger.press(action)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 1)
|
||||
await expect(image).toHaveAttribute("src", /^blob:/)
|
||||
const url = await image.getAttribute("src")
|
||||
if (action === "click") {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(new URL(requests[0]).pathname).toBe("/api/fs/read/chart%2520%20one.PNG")
|
||||
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
|
||||
await root.getByRole("button", { name: "Append read", exact: true }).click()
|
||||
await expect(image).toHaveAttribute("src", url!)
|
||||
expect(requests).toHaveLength(1)
|
||||
}
|
||||
if (action === "click") await trigger.click()
|
||||
if (action !== "click") await trigger.press(action)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(image).toHaveCount(0)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(url) =>
|
||||
fetch(url!).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
url,
|
||||
),
|
||||
).toBe(true)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 1)
|
||||
const url = await image.getAttribute("src")
|
||||
await root.getByRole("button", { name: "Unmount tools", exact: true }).click()
|
||||
await expect(image).toHaveCount(0)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(url) =>
|
||||
fetch(url!).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
url,
|
||||
),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
for (const width of [840, 390]) {
|
||||
for (const dir of ["ltr", "rtl"]) {
|
||||
story(`fits the server image inside the read row at ${width}px in ${dir}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.route("**/api/fs/read/**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640"><rect width="1200" height="640" fill="green" /></svg>',
|
||||
}),
|
||||
)
|
||||
await page.evaluate(
|
||||
async ({ fixture, dir }) => {
|
||||
document.documentElement.dir = dir
|
||||
const { mountReadImage } = await import(fixture)
|
||||
mountReadImage({ path: "./images/chart.svg", grouped: true })
|
||||
},
|
||||
{ fixture, dir },
|
||||
)
|
||||
const root = page.getByTestId("read-image-fixture")
|
||||
await root.getByRole("button", { name: "Read chart.svg", exact: true }).click()
|
||||
const image = root.getByRole("img", { name: "chart.svg", exact: true })
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 1200)
|
||||
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
|
||||
expect(
|
||||
await image.evaluate((image) => {
|
||||
const bounds = image.getBoundingClientRect()
|
||||
const row = image.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
|
||||
return bounds.width > 0 && bounds.left >= row.left && bounds.right <= row.right && bounds.bottom <= row.bottom
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("keeps an unavailable image read collapsible", async ({ page }) => {
|
||||
await page.route("**/api/fs/read/**", (route) => route.fulfill({ status: 404, body: "Not found" }))
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountReadImage } = await import(fixture)
|
||||
mountReadImage({ path: "/tmp/missing.png", grouped: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("read-image-fixture")
|
||||
const trigger = root.getByRole("button", { name: "Read missing.png", exact: true })
|
||||
const response = page.waitForResponse("**/api/fs/read/**")
|
||||
await trigger.click()
|
||||
expect((await response).status()).toBe(404)
|
||||
await expect(root.getByRole("img", { name: "missing.png", exact: true })).not.toHaveAttribute("src")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
@@ -283,7 +283,7 @@
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: 2px 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
|
||||
}
|
||||
|
||||
@@ -363,6 +363,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="read-image"] {
|
||||
min-width: 0;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="bash-output"] {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
|
||||
@@ -27,6 +27,8 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { ToolErrorCard } from "../components/tool-error-card"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Markdown } from "../components/markdown"
|
||||
import { createMarkdownImages } from "../components/markdown-image"
|
||||
import { useMarkdown } from "../context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -272,6 +274,12 @@ function readToolPath(input: Record<string, unknown>) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readImagePath(input: Record<string, unknown>) {
|
||||
const path = readToolPath(input)
|
||||
if (!path || !/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(path)) return
|
||||
return path.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
|
||||
if (typeof metadata?.name === "string") return metadata.name
|
||||
if (typeof input.id === "string") return input.id
|
||||
@@ -621,7 +629,9 @@ export function CurrentContextToolGroup(props: {
|
||||
<div data-slot="context-tool-group-item">
|
||||
<Show
|
||||
when={
|
||||
tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)
|
||||
tool().state.status !== "error" &&
|
||||
["read", "glob", "grep", "list"].includes(tool().name) &&
|
||||
!(tool().name === "read" && readImagePath(currentToolInput(tool())))
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
@@ -1049,6 +1059,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const image = createMemo(() => (props.status === "completed" ? readImagePath(props.input) : undefined))
|
||||
const args: string[] = []
|
||||
if (typeof props.input.offset === "number") args.push("offset=" + props.input.offset)
|
||||
if (typeof props.input.limit === "number") args.push("limit=" + props.input.limit)
|
||||
@@ -1071,12 +1082,22 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="glasses"
|
||||
hasContent={!!image()}
|
||||
defer
|
||||
onOpenChange={(open) => {
|
||||
props.onOpenChange?.(open)
|
||||
props.onContentRendered?.()
|
||||
}}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.read"),
|
||||
subtitle: getFilename(readToolPath(props.input) ?? ""),
|
||||
args,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Show when={image()} keyed>
|
||||
{(path) => <ReadImage path={path} onContentRendered={props.onContentRendered} />}
|
||||
</Show>
|
||||
</BasicTool>
|
||||
<Show when={paths().length > 0}>
|
||||
<div
|
||||
data-component="tool-loaded-item"
|
||||
@@ -1102,6 +1123,28 @@ ToolRegistry.register({
|
||||
},
|
||||
})
|
||||
|
||||
function ReadImage(props: { path: string; onContentRendered?: () => void }) {
|
||||
const markdown = useMarkdown()
|
||||
let root!: HTMLDivElement
|
||||
createEffect(() => {
|
||||
if (!markdown?.readImage) return
|
||||
const images = createMarkdownImages(markdown.readImage)
|
||||
images.update(root)
|
||||
onCleanup(() => images.dispose())
|
||||
})
|
||||
onMount(() => props.onContentRendered?.())
|
||||
return (
|
||||
<div ref={root} data-component="read-image">
|
||||
<img
|
||||
data-local-image={props.path}
|
||||
alt={getFilename(props.path)}
|
||||
onLoad={() => props.onContentRendered?.()}
|
||||
onError={() => props.onContentRendered?.()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "list",
|
||||
render(props) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { oneCellFrame, type OneCellMotion } from "../ui/one-cell-motion"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function OneCellSpinner(props: {
|
||||
animation: OneCellMotion
|
||||
color: ColorInput
|
||||
animations?: boolean
|
||||
speed?: number
|
||||
paused?: boolean
|
||||
glow?: boolean
|
||||
age?: number
|
||||
still?: string
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const [elapsed, setElapsed] = createSignal(0)
|
||||
const sequenced = () => !!props.animation.intro || !!props.animation.once || !!props.animation.pace
|
||||
const frame = createMemo(() => oneCellFrame(props.animation, elapsed()))
|
||||
const complete = createMemo(() => frame().complete)
|
||||
const base = createMemo(() => parseColor(props.color))
|
||||
const color = createMemo(() => {
|
||||
if (props.glow === false) return base()
|
||||
if (sequenced()) {
|
||||
const color = RGBA.clone(base())
|
||||
color.a *= frame().level
|
||||
return color
|
||||
}
|
||||
const palette = props.animation.levels?.map((level) => {
|
||||
const color = RGBA.clone(base())
|
||||
color.a *= level
|
||||
return color
|
||||
})
|
||||
return palette ? (frame: number) => palette[frame]! : base()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.animation
|
||||
props.animations
|
||||
setElapsed(props.age ?? 0)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!sequenced() || props.animations === false || props.paused || complete()) return
|
||||
let previous = performance.now()
|
||||
// Leave idle gaps: mini awaits renderer.idle() to flush and admit prompts.
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
const now = performance.now()
|
||||
setElapsed((value) => value + (now - previous) * (props.speed ?? 1))
|
||||
previous = now
|
||||
},
|
||||
Math.max(
|
||||
1000 / 60,
|
||||
Math.min(40, props.animation.interval / (props.speed ?? 1) / (props.animation.pace?.initial ?? 1)),
|
||||
),
|
||||
)
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
renderer.requestRender()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={1} height={1} flexShrink={0}>
|
||||
<Show when={props.animations !== false} fallback={<text fg={base()}>{props.still ?? "\u25aa"}</text>}>
|
||||
<spinner
|
||||
frames={sequenced() ? [frame().glyph] : props.animation.frames}
|
||||
interval={props.animation.interval / (props.speed ?? 1)}
|
||||
autoplay={!props.paused && !sequenced()}
|
||||
color={color()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -51,13 +51,7 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import {
|
||||
normalizePastedFilepath,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachment,
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { resolvePastedAttachments } from "./local-attachment"
|
||||
import { locationKey, useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
@@ -1455,36 +1449,19 @@ export function Prompt(props: PromptProps) {
|
||||
async function pasteInputText(text: string, changed: () => boolean) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
if (attachment) {
|
||||
if (changed()) return
|
||||
pasteLocalAttachment(filepath, attachment)
|
||||
return
|
||||
}
|
||||
|
||||
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||
if (filepaths.length > 1) {
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) break
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
if (attachments.length === filepaths.length) {
|
||||
if (changed()) return
|
||||
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||
const attachments = await resolvePastedAttachments(pastedContent, terminalEnvironment.platform)
|
||||
if (changed()) return
|
||||
if (attachments) {
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${attachment.filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
pasteAttachment(attachment)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (changed()) return
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
@@ -1509,18 +1486,6 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
}
|
||||
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
|
||||
@@ -26,6 +26,38 @@ export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMEN
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolvePastedAttachments(text: string, platform: string) {
|
||||
const pastedContent = text.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, platform)
|
||||
if (/^(https?):\/\//.test(filepath)) return undefined
|
||||
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
const attachments = attachment ? [{ filepath, attachment }] : []
|
||||
if (!attachment) {
|
||||
const filepaths = parsePastedFilepaths(pastedContent, platform)
|
||||
if (filepaths.length <= 1) return undefined
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) return undefined
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
}
|
||||
|
||||
return attachments.map((item) => {
|
||||
const filename = path.basename(item.filepath)
|
||||
if (item.attachment.type === "text") {
|
||||
return { type: "text" as const, content: item.attachment.content, filename }
|
||||
}
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: `data:${item.attachment.mime};base64,${Buffer.from(item.attachment.content).toString("base64")}`,
|
||||
filename,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".avif": "image/avif",
|
||||
".gif": "image/gif",
|
||||
|
||||
@@ -26,6 +26,24 @@ export const AttentionSoundName = Schema.Literals([
|
||||
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
|
||||
export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
|
||||
|
||||
export const MiniWorkSpinner = Schema.Literals([
|
||||
"block-soft-slide",
|
||||
"block-soft-sweep",
|
||||
"block-low-comet",
|
||||
"block-low-duet",
|
||||
"block-shuttle",
|
||||
"block-bridge",
|
||||
"block-squeeze",
|
||||
"small-toggle",
|
||||
"square-toggle",
|
||||
"grow-shrink",
|
||||
"quadrant-orbit",
|
||||
"crosshatch",
|
||||
"density-wave",
|
||||
"seed",
|
||||
])
|
||||
export type MiniWorkSpinner = Schema.Schema.Type<typeof MiniWorkSpinner>
|
||||
|
||||
export const Plugin = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
@@ -185,6 +203,9 @@ export const Info = Schema.Struct({
|
||||
splash: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide the entry and exit splash banners",
|
||||
}),
|
||||
work_spinner: Schema.optional(MiniWorkSpinner).annotate({
|
||||
description: "Work spinner animation in the Mini footer (default: block-soft-slide)",
|
||||
}),
|
||||
mono: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use monochrome ASCII output",
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StoryFooter } from "./footer"
|
||||
import { mermanLayoutsStory } from "./merman-layouts"
|
||||
import { sessionTabsStory } from "./session-tabs"
|
||||
import { sessionLocationMissingStory } from "./session-location-missing"
|
||||
import { oneCellSpinnerStory } from "./one-cell-spinner"
|
||||
|
||||
/**
|
||||
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
||||
@@ -16,7 +17,7 @@ export type Story = {
|
||||
render: (context: Plugin.Context) => JSX.Element
|
||||
}
|
||||
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, oneCellSpinnerStory]
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { SEED_LAUNCH, SEED_WORK, WORK_SPINNERS, type OneCellMotion } from "../../../ui/one-cell-motion"
|
||||
import { SUBCELL_SPINNERS } from "./subcell-spinner.fixtures"
|
||||
|
||||
export type SpinnerFixture = OneCellMotion & {
|
||||
name: string
|
||||
description: string
|
||||
launch?: OneCellMotion
|
||||
}
|
||||
|
||||
function heldFrames(glyphs: string, holds: number[] = []) {
|
||||
return Array.from(glyphs).flatMap((glyph, index) => Array.from({ length: holds[index] ?? 1 }, () => glyph))
|
||||
}
|
||||
|
||||
function sequence(
|
||||
name: string,
|
||||
description: string,
|
||||
glyphs: string,
|
||||
interval: number,
|
||||
holds?: number[],
|
||||
): SpinnerFixture {
|
||||
return { name, description, frames: heldFrames(glyphs, holds), interval }
|
||||
}
|
||||
|
||||
function pulse(
|
||||
name: string,
|
||||
description: string,
|
||||
glyphs: string,
|
||||
duration: number,
|
||||
level: (phase: number) => number,
|
||||
holds?: number[],
|
||||
): SpinnerFixture {
|
||||
const shapes = heldFrames(glyphs, holds)
|
||||
const phases = Array.from({ length: duration / 40 }, (_, index) => index / (duration / 40))
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
frames: phases.map((phase) => shapes[Math.floor(phase * shapes.length)]!),
|
||||
interval: 40,
|
||||
// Keep a visible floor: a working indicator must never blink out entirely.
|
||||
levels: phases.map((phase) => 0.3 + 0.7 * level(phase)),
|
||||
}
|
||||
}
|
||||
|
||||
const breathe = (phase: number) => (1 - Math.cos(phase * 2 * Math.PI)) / 2
|
||||
const ember = (phase: number) => (phase < 0.1 ? phase / 0.1 : ((1 - phase) / 0.9) ** 3)
|
||||
const seedBreathe = pulse("Seed breathe", "A still seed carries a slow breath of light.", "\u25aa", 1600, breathe)
|
||||
const seedToggle = pulse(
|
||||
"Seed toggle",
|
||||
"An outline fills with light, then opens again.",
|
||||
"\u25ab\u25aa\u25aa\u25ab",
|
||||
1600,
|
||||
breathe,
|
||||
)
|
||||
export const ONE_CELL_SPINNERS: SpinnerFixture[] = [
|
||||
{
|
||||
...WORK_SPINNERS["small-toggle"],
|
||||
name: "Small toggle",
|
||||
description: "A small square opens and closes in an even rhythm.",
|
||||
},
|
||||
{
|
||||
...WORK_SPINNERS["square-toggle"],
|
||||
name: "Square toggle",
|
||||
description: "A larger square opens and closes at a slower pace.",
|
||||
},
|
||||
{
|
||||
...WORK_SPINNERS["grow-shrink"],
|
||||
name: "Grow / shrink",
|
||||
description: "An outline fills, grows, and returns to a seed.",
|
||||
},
|
||||
sequence(
|
||||
"Inset bloom",
|
||||
"A square within a square opens into a full bloom.",
|
||||
"\u25ab\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
120,
|
||||
[3],
|
||||
),
|
||||
sequence("Hollow bloom", "The outline grows before its center fills.", "\u25ab\u25a1\u25a3\u25a0\u25a3\u25a1", 160, [
|
||||
3,
|
||||
]),
|
||||
sequence(
|
||||
"Corner orbit",
|
||||
"A small square traces four corners without leaving its cell.",
|
||||
"\u25f0\u25f3\u25f2\u25f1",
|
||||
160,
|
||||
),
|
||||
{
|
||||
...WORK_SPINNERS["quadrant-orbit"],
|
||||
name: "Quadrant orbit",
|
||||
description: "Four corners take their turn, clockwise.",
|
||||
},
|
||||
sequence("Half rotation", "Light and shade circle a still square.", "\u25e7\u2b12\u25e8\u2b13", 200),
|
||||
{ ...WORK_SPINNERS.crosshatch, name: "Crosshatch", description: "Diagonal threads cross, then change direction." },
|
||||
{
|
||||
...WORK_SPINNERS["density-wave"],
|
||||
name: "Density wave",
|
||||
description: "Grain gathers into a block, then thins. The color stays still.",
|
||||
},
|
||||
sequence(
|
||||
"Fill / drain",
|
||||
"A narrow tide rises, then returns.",
|
||||
"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\u2587\u2586\u2585\u2584\u2583\u2582",
|
||||
80,
|
||||
[2, 2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2],
|
||||
),
|
||||
sequence(
|
||||
"Double beat",
|
||||
"Two quick swells, then a quiet seed.",
|
||||
"\u25aa\u25a0\u25aa\u25a0\u25aa",
|
||||
100,
|
||||
[1, 1, 2, 1, 11],
|
||||
),
|
||||
sequence("Beacon", "One large flash settles into a seed that stays in sight.", "\u25a0\u25a3\u25aa", 120, [1, 1, 12]),
|
||||
sequence(
|
||||
"Held bloom",
|
||||
"A seed swells, holds its bloom, then rests.",
|
||||
"\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
160,
|
||||
[4, 1, 2, 1, 2],
|
||||
),
|
||||
seedBreathe,
|
||||
pulse("Square breathe", "A larger square takes a longer breath.", "\u25a0", 2400, breathe),
|
||||
pulse("Inset breathe", "A square within a square holds a soft breath of light.", "\u25a3", 2000, breathe),
|
||||
pulse(
|
||||
"Bloom + glow",
|
||||
"A small bloom grows with the light, then recedes.",
|
||||
"\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
1600,
|
||||
breathe,
|
||||
[6, 3, 2, 3, 6],
|
||||
),
|
||||
pulse("Soft heartbeat", "Two soft beats of light, then a quiet glow.", "\u25aa", 1600, (phase) =>
|
||||
Math.max(Math.exp(-(((phase - 0.2) / 0.08) ** 2)), 0.75 * Math.exp(-(((phase - 0.4) / 0.08) ** 2))),
|
||||
),
|
||||
pulse("Ember", "A quick spark leaves a long glow in a still square.", "\u25a3", 1600, ember),
|
||||
{ ...seedToggle, launch: SEED_LAUNCH },
|
||||
pulse("Seed ember", "A small, still seed catches light and lets it linger.", "\u25aa", 1600, ember),
|
||||
{
|
||||
...SEED_WORK,
|
||||
name: "Seed handoff",
|
||||
description: "A spark lingers, then the breath grows quiet.",
|
||||
launch: SEED_LAUNCH,
|
||||
},
|
||||
...SUBCELL_SPINNERS,
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { OneCellSpinner } from "../../../component/one-cell-spinner"
|
||||
import { useConfig } from "../../../config"
|
||||
import { entrySplashLayout } from "../../../mini/splash"
|
||||
import { stringWidth } from "../../../util/string-width"
|
||||
import { StoryFooter } from "./footer"
|
||||
import type { Story } from "./index"
|
||||
import { ONE_CELL_SPINNERS } from "./one-cell-spinner.fixtures"
|
||||
|
||||
function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const config = useConfig()
|
||||
const [selected, setSelected] = createSignal(39)
|
||||
const [speed, setSpeed] = createSignal(1)
|
||||
const [animations, setAnimations] = createSignal(config.data.animations ?? true)
|
||||
const [paused, setPaused] = createSignal(false)
|
||||
const [glow, setGlow] = createSignal(true)
|
||||
const [solo, setSolo] = createSignal(false)
|
||||
const [epoch, setEpoch] = createSignal(0)
|
||||
const [age, setAge] = createSignal(0)
|
||||
const animation = () => ONE_CELL_SPINNERS[selected()]!
|
||||
const timing = createMemo(() => {
|
||||
const item = animation()
|
||||
const cycle = (item.frames.length * item.interval) / speed()
|
||||
if (!item.pace) return `${Math.round((item.interval / speed()) * 10) / 10}ms tick / ${cycle}ms cycle`
|
||||
return `${(cycle / item.pace.initial / 1000).toFixed(2)}s to ${(cycle / item.pace.final / 1000).toFixed(2)}s cycle`
|
||||
})
|
||||
const previewWidth = () => (solo() ? Math.min(dimensions().width, 64) : dimensions().width)
|
||||
const speeds = createMemo(() => (dimensions().width >= 60 ? [0.5, 1, 2] : [speed()]))
|
||||
const splash = createMemo(() =>
|
||||
entrySplashLayout({ width: Math.max(1, previewWidth() - 8), version: "1.18.4", detail: "~/src/opencode" }),
|
||||
)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const revealSelected = () => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const rows = scroll.viewport.height
|
||||
scroll.scrollTo(Math.max(0, Math.min(selected() - Math.floor(rows / 2), ONE_CELL_SPINNERS.length - rows)))
|
||||
}
|
||||
createEffect(revealSelected)
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: solo() ? "Leave focus" : "Back to storybook",
|
||||
group: "Storybook",
|
||||
run: () => {
|
||||
if (solo()) return setSolo(false)
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "up,k",
|
||||
title: "Previous animation",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((value) => (value + ONE_CELL_SPINNERS.length - 1) % ONE_CELL_SPINNERS.length),
|
||||
},
|
||||
{
|
||||
bind: "down,j",
|
||||
title: "Next animation",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((value) => (value + 1) % ONE_CELL_SPINNERS.length),
|
||||
},
|
||||
{
|
||||
bind: "s",
|
||||
title: "Cycle preview speed",
|
||||
group: "Storybook",
|
||||
run: () => setSpeed((value) => (value === 0.5 ? 1 : value === 1 ? 2 : 0.5)),
|
||||
},
|
||||
{ bind: "space", title: "Pause / resume", group: "Storybook", run: () => setPaused((value) => !value) },
|
||||
{ bind: "a", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
|
||||
{ bind: "g", title: "Toggle intensity pulse", group: "Storybook", run: () => setGlow((value) => !value) },
|
||||
{
|
||||
bind: "f",
|
||||
title: "Focus selected animation",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setSolo((value) => !value)
|
||||
if (solo()) setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
bind: "p",
|
||||
title: "Replay animation",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setAge(0)
|
||||
setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
...(animation().pace
|
||||
? [
|
||||
{
|
||||
bind: "t",
|
||||
title: "Cycle work age",
|
||||
group: "Storybook",
|
||||
run: () => setAge((value) => (value === 0 ? 30_000 : value === 30_000 ? 60_000 : 0)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset comparison",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setSelected(39)
|
||||
setSpeed(1)
|
||||
setAnimations(config.data.animations ?? true)
|
||||
setPaused(false)
|
||||
setGlow(true)
|
||||
setSolo(false)
|
||||
setAge(0)
|
||||
setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background.default}
|
||||
justifyContent={solo() ? "center" : undefined}
|
||||
alignItems={solo() ? "center" : undefined}
|
||||
>
|
||||
<Show when={!solo()}>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
one-cell motion lab.
|
||||
</text>
|
||||
</Show>
|
||||
<For each={[epoch()]}>
|
||||
{() => (
|
||||
<>
|
||||
<Show when={!solo()}>
|
||||
<box flexDirection="row" height={1} flexShrink={0} paddingLeft={1}>
|
||||
<text width={22} fg={theme.text.subdued}>
|
||||
pattern
|
||||
</text>
|
||||
<For each={speeds()}>
|
||||
{(value) => (
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
{value}x
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<text fg={theme.text.subdued}>cycle @1x</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={scroll}
|
||||
flexGrow={1}
|
||||
minHeight={1}
|
||||
viewportOptions={{ paddingLeft: 1 }}
|
||||
onSizeChange={() => queueMicrotask(revealSelected)}
|
||||
>
|
||||
<For each={ONE_CELL_SPINNERS}>
|
||||
{(item, index) => (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
<text
|
||||
width={22}
|
||||
wrapMode="none"
|
||||
fg={index() === selected() ? theme.text.formfield.selected : theme.text.formfield.default}
|
||||
>
|
||||
{index() === selected() ? ">" : " "}
|
||||
{String(index() + 1).padStart(2)} {item.name.toLowerCase()}.
|
||||
</text>
|
||||
<For each={speeds()}>
|
||||
{(value) => (
|
||||
<box width={7} flexShrink={0}>
|
||||
<OneCellSpinner
|
||||
animation={item}
|
||||
age={age()}
|
||||
speed={value}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<text width={10} fg={theme.text.subdued}>
|
||||
{item.pace ? "adaptive" : `${item.frames.length * item.interval}ms`}
|
||||
</text>
|
||||
<Show when={dimensions().width >= 80}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{[...new Set(item.frames)].join(" ")}
|
||||
{item.levels ? " + intensity" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<box
|
||||
width={previewWidth()}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
alignItems={solo() ? "center" : undefined}
|
||||
>
|
||||
<text fg={theme.text.default} maxWidth="100%" attributes={solo() ? TextAttributes.BOLD : 0}>
|
||||
<Show when={!solo()}>{String(selected() + 1).padStart(2, "0")} / </Show>
|
||||
{animation().name.toLowerCase()}.
|
||||
</text>
|
||||
<Show when={!solo()}>
|
||||
<text fg={theme.text.subdued} maxWidth="100%">
|
||||
{animation().description}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>
|
||||
{speed()}x: {timing()}
|
||||
</text>
|
||||
</Show>
|
||||
<box
|
||||
width={
|
||||
solo() ? Math.min(previewWidth() - 2, stringWidth(splash().label + splash().metadata) + 7) : "100%"
|
||||
}
|
||||
marginTop={solo() ? 1 : 0}
|
||||
>
|
||||
<box height={1} flexDirection="row">
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
work
|
||||
</text>
|
||||
<OneCellSpinner
|
||||
animation={animation()}
|
||||
age={age()}
|
||||
speed={speed()}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
/>
|
||||
<text fg={theme.text.default}> esc stop</text>
|
||||
</box>
|
||||
<box height={1} flexDirection="row">
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
launch
|
||||
</text>
|
||||
<OneCellSpinner
|
||||
animation={animation().launch ?? animation()}
|
||||
speed={speed()}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.default}
|
||||
/>
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
{splash().label.slice(1)}
|
||||
<span style={{ fg: theme.text.subdued }}>{splash().metadata}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!solo()}>
|
||||
<StoryFooter
|
||||
context={props.context}
|
||||
title="motion lab."
|
||||
details={[animations() ? (paused() ? "paused" : "playing") : "motion off", glow() ? "glow on" : "shape only"]}
|
||||
status={animation().pace ? `start at ${age() / 1000}s | slows after 30s` : undefined}
|
||||
controls={[
|
||||
{ shortcut: "j/k", label: "select" },
|
||||
{ shortcut: "s", label: "speed" },
|
||||
{ shortcut: "space", label: "pause" },
|
||||
{ shortcut: "a", label: "motion" },
|
||||
{ shortcut: "g", label: "glow" },
|
||||
{ shortcut: "f", label: "focus" },
|
||||
{ shortcut: "p", label: "replay" },
|
||||
...(animation().pace ? [{ shortcut: "t", label: "age 0/30/60s" }] : []),
|
||||
{ shortcut: "r", label: "reset" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export const oneCellSpinnerStory: Story = {
|
||||
id: "one-cell-spinners",
|
||||
title: "one-cell spinners.",
|
||||
render: (context) => <OneCellSpinnerStory context={context} />,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
BLOCK_LOW_COMET,
|
||||
BLOCK_SOFT_SLIDE,
|
||||
BLOCK_SOFT_SWEEP,
|
||||
SEED_LAUNCH,
|
||||
WORK_SPINNERS,
|
||||
} from "../../../ui/one-cell-motion"
|
||||
import { octantGlyph } from "../../../ui/subcell"
|
||||
import type { SpinnerFixture } from "./one-cell-spinner.fixtures"
|
||||
|
||||
const perimeter = [0, 1, 3, 5, 7, 6, 4, 2]
|
||||
const patterns = [
|
||||
{
|
||||
name: "comet",
|
||||
description: "Three pixels chase the edge.",
|
||||
interval: 100,
|
||||
masks: perimeter.map(
|
||||
(point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!) | (1 << perimeter[(index + 6) % 8]!),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "orbit",
|
||||
description: "Two pixels keep a steady orbit.",
|
||||
interval: 140,
|
||||
masks: perimeter.map((point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!)),
|
||||
},
|
||||
{
|
||||
name: "duet",
|
||||
description: "Two pairs circle opposite edges.",
|
||||
interval: 180,
|
||||
masks: perimeter
|
||||
.slice(0, 4)
|
||||
.map((_, index) => [0, 3, 4, 7].reduce((mask, offset) => mask | (1 << perimeter[(index + offset) % 8]!), 0)),
|
||||
},
|
||||
{
|
||||
name: "scan",
|
||||
description: "A row sweeps down, pauses, and returns.",
|
||||
interval: 130,
|
||||
masks: [0, 0, 1, 2, 3, 3, 2, 1].map((row) => 3 << (row * 2)),
|
||||
},
|
||||
{
|
||||
name: "weave",
|
||||
description: "Four pixels pass from side to side.",
|
||||
interval: 200,
|
||||
masks: [0x55, 0x69, 0xaa, 0x96],
|
||||
},
|
||||
{
|
||||
name: "tide",
|
||||
description: "Rows rise from the floor, then recede.",
|
||||
interval: 160,
|
||||
masks: [0xc0, 0xc0, 0xf0, 0xfc, 0xff, 0xfc, 0xf0],
|
||||
},
|
||||
{
|
||||
name: "low comet",
|
||||
description: "The tail recedes before the head moves. The top row stays empty.",
|
||||
motion: BLOCK_LOW_COMET,
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "low duet",
|
||||
description: "Two tails recede, then the heads move. The top row stays empty.",
|
||||
motion: WORK_SPINNERS["block-low-duet"],
|
||||
blockOnly: true,
|
||||
},
|
||||
// The middle four octants are bits 2-5: left column 0x14, right column 0x28.
|
||||
{
|
||||
name: "shuttle",
|
||||
description: "A narrow bar moves left and right through the middle four.",
|
||||
motion: WORK_SPINNERS["block-shuttle"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "bridge",
|
||||
description: "The bar stretches across the middle four, then settles opposite.",
|
||||
motion: WORK_SPINNERS["block-bridge"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "soft sweep",
|
||||
description: "A staggered wipe crosses the middle four and retraces its path.",
|
||||
motion: BLOCK_SOFT_SWEEP,
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "squeeze",
|
||||
description: "The middle bar folds, hops sideways, and opens.",
|
||||
motion: WORK_SPINNERS["block-squeeze"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "soft slide",
|
||||
description: "The middle bar softens before each sideways step.",
|
||||
blockOnly: true,
|
||||
motion: BLOCK_SOFT_SLIDE,
|
||||
},
|
||||
]
|
||||
|
||||
export const SUBCELL_SPINNERS: SpinnerFixture[] = patterns.flatMap((pattern) =>
|
||||
(pattern.blockOnly ? ["block"] : ["block", "dot"]).map((style) => ({
|
||||
name: `${style} ${pattern.name}`,
|
||||
description: `${pattern.description} ${style === "block" ? "2x4 octants need a recent font." : "2x4 braille dots."}`,
|
||||
...(pattern.motion ?? {
|
||||
interval: pattern.interval!,
|
||||
frames: pattern.masks!.map((mask) => {
|
||||
if (style === "dot") {
|
||||
// Braille numbers its dots by column rather than by raster row.
|
||||
const dots = [0, 3, 1, 4, 2, 5, 6, 7].reduce((value, bit, index) => value | (((mask >> index) & 1) << bit), 0)
|
||||
return String.fromCodePoint(0x2800 + dots)
|
||||
}
|
||||
return octantGlyph(mask)
|
||||
}),
|
||||
}),
|
||||
launch: SEED_LAUNCH,
|
||||
})),
|
||||
)
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "./types"
|
||||
|
||||
const KINDS = [
|
||||
"motion",
|
||||
"markdown",
|
||||
"table",
|
||||
"text",
|
||||
@@ -152,6 +153,7 @@ type State = {
|
||||
perms: Map<string, Perm>
|
||||
forms: Map<string, FormRequest>
|
||||
started: Set<string>
|
||||
motion?: AbortController
|
||||
}
|
||||
|
||||
type Input = {
|
||||
@@ -814,6 +816,17 @@ function emitForm(state: State, kind: FormKind = "question"): void {
|
||||
}
|
||||
|
||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||
if (kind === "motion") {
|
||||
note(state.footer, "Working indicator demo: 70 seconds, no model calls. Interrupt to stop early.")
|
||||
const controller = new AbortController()
|
||||
state.motion = controller
|
||||
try {
|
||||
await wait(70_000, signal ? AbortSignal.any([controller.signal, signal]) : controller.signal)
|
||||
} finally {
|
||||
state.motion = undefined
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (kind === "text") {
|
||||
await emitText(state, body || SAMPLE_MARKDOWN, signal)
|
||||
return true
|
||||
@@ -900,6 +913,7 @@ function intro(state: State): void {
|
||||
"- /form question",
|
||||
"- /form external",
|
||||
"- /fmt markdown",
|
||||
"- /fmt motion",
|
||||
"- /fmt table",
|
||||
"- /fmt text your custom text",
|
||||
].join("\n"),
|
||||
@@ -1037,6 +1051,11 @@ export function createRunDemo(input: Input) {
|
||||
return {
|
||||
start,
|
||||
prompt,
|
||||
interrupt() {
|
||||
if (!state.motion) return false
|
||||
state.motion.abort()
|
||||
return true
|
||||
},
|
||||
permission,
|
||||
formReply,
|
||||
formCancel,
|
||||
|
||||
@@ -194,6 +194,11 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
const raw = cleanRunText(commit.text)
|
||||
const mono = options?.mono === true
|
||||
|
||||
if (commit.image) {
|
||||
const caption = raw.trim() || "Image"
|
||||
return commit.kind === "user" ? userBody(caption, mono) : textBody(monoToolText(caption, mono))
|
||||
}
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw, mono)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
|
||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { Config } from "../config"
|
||||
import { OneCellSpinner } from "../component/one-cell-spinner"
|
||||
import { SEED_MONO, WORK_SPINNERS } from "../ui/one-cell-motion"
|
||||
import {
|
||||
FOOTER_COMPACT_WIDTH,
|
||||
RunFooterMenu,
|
||||
createFooterMenuState,
|
||||
footerMenuText,
|
||||
type RunFooterMenuItem,
|
||||
} from "./footer.menu"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { monoShortcut } from "./mono"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
@@ -78,7 +88,12 @@ const PANEL_FRAME_ROWS = 6
|
||||
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const SUBAGENT_LIST_ROWS = 12
|
||||
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const PANEL_PAGE = PANEL_LIST_ROWS - 1
|
||||
export function footerPanelLayout(height: number, limit = PANEL_LIST_ROWS) {
|
||||
const available = Math.max(3, height - 1)
|
||||
const compact = available < limit + PANEL_FRAME_ROWS
|
||||
const frame = compact ? 2 : PANEL_FRAME_ROWS
|
||||
return { compact, frame, limit: Math.max(1, Math.min(limit, available - frame)) }
|
||||
}
|
||||
const HALF_BLOCK_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
@@ -138,10 +153,17 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
onKey?: (event: KeyEvent, item: T | undefined) => boolean
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const layout = createMemo(() => {
|
||||
term()
|
||||
// The panel mounts before the footer expands, so its initial render height is stale.
|
||||
return footerPanelLayout(renderer.terminalHeight, input.limit)
|
||||
})
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const items = createMemo<T[]>(() => match(query(), input.entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: () => layout().limit })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
@@ -161,7 +183,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
input.onRows?.(menu.rows() + layout().frame)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
@@ -201,13 +223,13 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
|
||||
if (name === "pageup") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() - PANEL_PAGE)
|
||||
menu.reveal(menu.selected() - Math.max(1, menu.limit() - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() + PANEL_PAGE)
|
||||
menu.reveal(menu.selected() + Math.max(1, menu.limit() - 1))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,6 +266,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
setQuery,
|
||||
items,
|
||||
menu,
|
||||
layout,
|
||||
inputRef(input: InputRenderable) {
|
||||
field = input
|
||||
},
|
||||
@@ -263,50 +286,66 @@ function PanelShell(props: {
|
||||
children: JSX.Element
|
||||
hint?: string
|
||||
mono?: boolean
|
||||
background?: boolean
|
||||
layout: ReturnType<typeof footerPanelLayout>
|
||||
}) {
|
||||
const background = () => props.theme().shade
|
||||
const term = useTerminalDimensions()
|
||||
const pad = () => (term().width < FOOTER_COMPACT_WIDTH ? 1 : panelPad(props.mono))
|
||||
const header = createMemo(() => {
|
||||
const width = Math.max(0, term().width - pad() * 2 - 4)
|
||||
const title = footerMenuText(props.title, width, props.mono)
|
||||
const count = countLabel(props.count, props.total, props.query)
|
||||
const showCount = props.countVisible !== false && stringWidth(props.title) + stringWidth(count) + 1 <= width
|
||||
const hint =
|
||||
props.hint &&
|
||||
stringWidth(props.title) + (showCount ? stringWidth(count) + 1 : 0) + stringWidth(props.hint) + 3 <= width
|
||||
return { title, count: showCount ? count : undefined, hint: hint ? props.hint : undefined }
|
||||
})
|
||||
const background = () => (props.background === false ? "transparent" : props.theme().shade)
|
||||
const content = (
|
||||
<>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
paddingLeft={pad()}
|
||||
paddingRight={pad()}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
gap={0}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
|
||||
{props.title}
|
||||
{header().title}
|
||||
</text>
|
||||
{props.countVisible !== false ? (
|
||||
{header().count ? (
|
||||
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
||||
{countLabel(props.count, props.total, props.query)}
|
||||
{" " + header().count}
|
||||
</text>
|
||||
) : null}
|
||||
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
||||
<box minWidth={1} flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
||||
{header().hint ? `${header().hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
||||
</text>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
paddingLeft={pad()}
|
||||
paddingRight={pad()}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<input
|
||||
width="100%"
|
||||
focusedBackgroundColor={background()}
|
||||
focusedTextColor={props.theme().text}
|
||||
focusedBackgroundColor={props.background === false ? "transparent" : props.theme().formfieldFocusedBg}
|
||||
focusedTextColor={
|
||||
props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText
|
||||
}
|
||||
placeholder={props.placeholder}
|
||||
placeholderColor={props.theme().muted}
|
||||
cursorColor={props.theme().highlight}
|
||||
cursorColor={props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText}
|
||||
onInput={props.onQuery}
|
||||
ref={(input) => {
|
||||
props.inputRef(input)
|
||||
@@ -319,7 +358,7 @@ function PanelShell(props: {
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
|
||||
{props.children}
|
||||
</box>
|
||||
@@ -330,8 +369,14 @@ function PanelShell(props: {
|
||||
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{content}
|
||||
</box>
|
||||
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{props.mono ? null : (
|
||||
<box
|
||||
width="100%"
|
||||
height={props.layout.compact ? 0 : 1}
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.layout.compact || props.mono || props.background === false ? null : (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
@@ -562,6 +607,7 @@ export function RunCommandMenuBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Commands"
|
||||
layout={controller.layout()}
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
@@ -577,8 +623,9 @@ export function RunCommandMenuBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -609,6 +656,7 @@ export function RunAgentSelectBody(props: {
|
||||
display: agent.id,
|
||||
description: agent.description,
|
||||
footer: props.current() === agent.id ? "current" : undefined,
|
||||
footerTone: "selection" as const,
|
||||
keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`,
|
||||
id: agent.id,
|
||||
current: props.current() === agent.id,
|
||||
@@ -625,6 +673,7 @@ export function RunAgentSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select agent"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -639,8 +688,9 @@ export function RunAgentSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No agents found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -659,6 +709,7 @@ export function RunSettingsBody(props: {
|
||||
onClose: () => void
|
||||
onChange: (change: MiniSettingChange) => void | Promise<void>
|
||||
mono?: boolean
|
||||
animations?: boolean
|
||||
}) {
|
||||
const [saving, setSaving] = createSignal<keyof MiniSettings>()
|
||||
const entries = createMemo<SettingEntry[]>(() => [
|
||||
@@ -666,6 +717,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Thinking",
|
||||
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
|
||||
footerTone: saving() === "thinking" ? "running" : "selection",
|
||||
keywords: `thinking reasoning ${props.settings().thinking}`,
|
||||
key: "thinking",
|
||||
},
|
||||
@@ -673,6 +725,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Shell",
|
||||
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
|
||||
footerTone: saving() === "shell_output" ? "running" : "selection",
|
||||
keywords: `shell tool command output ${props.settings().shell_output}`,
|
||||
key: "shell_output",
|
||||
},
|
||||
@@ -680,6 +733,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Turn summary",
|
||||
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
|
||||
footerTone: saving() === "turn_summary" ? "running" : "selection",
|
||||
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
||||
key: "turn_summary",
|
||||
},
|
||||
@@ -687,6 +741,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Footer details",
|
||||
footer: saving() === "footer" ? "saving" : props.settings().footer,
|
||||
footerTone: saving() === "footer" ? "running" : "selection",
|
||||
keywords: `footer status activity model context usage ${props.settings().footer}`,
|
||||
key: "footer",
|
||||
},
|
||||
@@ -694,6 +749,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Splash",
|
||||
footer: saving() === "splash" ? "saving" : props.settings().splash,
|
||||
footerTone: saving() === "splash" ? "running" : "selection",
|
||||
keywords: `splash entry exit banner ${props.settings().splash}`,
|
||||
key: "splash",
|
||||
},
|
||||
@@ -701,16 +757,46 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Monochrome UI",
|
||||
footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off",
|
||||
footerTone: saving() === "mono" ? "running" : "selection",
|
||||
keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`,
|
||||
key: "mono",
|
||||
},
|
||||
{
|
||||
category: "Terminal",
|
||||
display: "Work spinner",
|
||||
icon: (color) => (
|
||||
<OneCellSpinner
|
||||
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.settings().work_spinner]}
|
||||
color={color}
|
||||
animations={props.animations}
|
||||
glow={!props.mono}
|
||||
still={props.mono ? "*" : undefined}
|
||||
/>
|
||||
),
|
||||
footer:
|
||||
saving() === "work_spinner"
|
||||
? "saving"
|
||||
: props.settings().work_spinner.replace("block-", "").replaceAll("-", " "),
|
||||
footerTone: saving() === "work_spinner" ? "running" : "selection",
|
||||
keywords: `work spinner animation ${props.settings().work_spinner}`,
|
||||
key: "work_spinner",
|
||||
},
|
||||
])
|
||||
const change = (item: SettingEntry) => {
|
||||
const change = (item: SettingEntry, direction = 1) => {
|
||||
if (saving()) return
|
||||
const spinners = Config.MiniWorkSpinner.literals
|
||||
const next: MiniSettingChange =
|
||||
item.key === "mono"
|
||||
? { key: "mono", value: !props.settings().mono }
|
||||
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
||||
item.key === "work_spinner"
|
||||
? {
|
||||
key: "work_spinner",
|
||||
value:
|
||||
spinners[
|
||||
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
|
||||
]!,
|
||||
}
|
||||
: item.key === "mono"
|
||||
? { key: "mono", value: !props.settings().mono }
|
||||
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
||||
setSaving(item.key)
|
||||
void Promise.resolve(props.onChange(next))
|
||||
.catch(() => {})
|
||||
@@ -725,7 +811,7 @@ export function RunSettingsBody(props: {
|
||||
const name = event.name.toLowerCase()
|
||||
if (name !== "left" && name !== "right") return false
|
||||
event.preventDefault()
|
||||
if (item) change(item)
|
||||
if (item) change(item, name === "left" ? -1 : 1)
|
||||
return true
|
||||
},
|
||||
})
|
||||
@@ -733,6 +819,7 @@ export function RunSettingsBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Settings"
|
||||
layout={controller.layout()}
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
@@ -749,8 +836,9 @@ export function RunSettingsBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No settings found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -785,6 +873,12 @@ export function RunSubagentSelectBody(props: {
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
footerTone:
|
||||
item.status === "running" || item.status === "error"
|
||||
? item.status
|
||||
: item.status === "completed"
|
||||
? ("success" as const)
|
||||
: undefined,
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
@@ -810,6 +904,7 @@ export function RunSubagentSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select subagent"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -826,7 +921,8 @@ export function RunSubagentSelectBody(props: {
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No subagents found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -885,6 +981,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Queued prompts"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -901,7 +998,8 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -943,6 +1041,7 @@ export function RunSkillSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Skills"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -957,8 +1056,9 @@ export function RunSkillSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -983,7 +1083,8 @@ export function RunVariantSelectBody(props: {
|
||||
{
|
||||
category: "",
|
||||
display: "Default",
|
||||
description: props.current() === undefined ? "current" : undefined,
|
||||
footer: props.current() === undefined ? "current" : undefined,
|
||||
footerTone: "selection",
|
||||
keywords: "default",
|
||||
variant: undefined,
|
||||
current: props.current() === undefined,
|
||||
@@ -991,7 +1092,8 @@ export function RunVariantSelectBody(props: {
|
||||
...props.variants().map((variant) => ({
|
||||
category: "",
|
||||
display: variant,
|
||||
description: props.current() === variant ? "current" : undefined,
|
||||
footer: props.current() === variant ? "current" : undefined,
|
||||
footerTone: "selection" as const,
|
||||
keywords: variant,
|
||||
variant,
|
||||
current: props.current() === variant,
|
||||
@@ -1008,6 +1110,7 @@ export function RunVariantSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select variant"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -1022,8 +1125,9 @@ export function RunVariantSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -1066,6 +1170,7 @@ export function RunModelSelectBody(props: {
|
||||
category: provider.name,
|
||||
display: title,
|
||||
footer,
|
||||
footerTone: current ? ("selection" as const) : undefined,
|
||||
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
|
||||
current,
|
||||
}
|
||||
@@ -1096,6 +1201,7 @@ export function RunModelSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select model"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -1104,24 +1210,26 @@ export function RunModelSelectBody(props: {
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
mono={props.mono}
|
||||
background={false}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={() =>
|
||||
controller.query().trim()
|
||||
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
|
||||
controller.query().trim() ||
|
||||
(controller.layout().compact && new Set(controller.items().map((item) => item.providerID)).size > 1)
|
||||
? controller.items().map((item) => ({ ...item, footer: item.providerName, footerTone: undefined }))
|
||||
: controller.items()
|
||||
}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty={props.providers() ? "No results found" : "Models loading"}
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
mono={props.mono}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { BoxRenderable, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import {
|
||||
createFormBodyState,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import type { FormBodyState } from "./form.shared"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function RunFormBody(props: {
|
||||
request: MiniFormRequest
|
||||
@@ -45,6 +46,11 @@ export function RunFormBody(props: {
|
||||
onState?: (state: FormBodyState) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const [contentHeight, setContentHeight] = createSignal(1)
|
||||
const [viewportHeight, setViewportHeight] = createSignal(0)
|
||||
const compact = () => size().width < 56 || size().height < 12
|
||||
const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request))
|
||||
const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => {
|
||||
const value = typeof next === "function" ? next(state()) : next
|
||||
@@ -66,11 +72,39 @@ export function RunFormBody(props: {
|
||||
const custom = createMemo(() => formCustom(current()))
|
||||
const textual = createMemo(() => formTextual(current()))
|
||||
const multiple = createMemo(() => current()?.type === "multiselect")
|
||||
const editing = () => !unsupported() && !confirm() && (textual() || state().editing)
|
||||
const message = createMemo(() => {
|
||||
const value = props.request.metadata?.message
|
||||
return typeof value === "string" ? value : undefined
|
||||
})
|
||||
let area: TextareaRenderable | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const choices = new Map<number, BoxRenderable>()
|
||||
|
||||
const revealChoice = () => {
|
||||
const row = choices.get(state().selected)
|
||||
if (!scroll || scroll.isDestroyed || !row || row.isDestroyed || state().editing || confirm()) return
|
||||
if (row.y < scroll.viewport.y) scroll.scrollBy(row.y - scroll.viewport.y)
|
||||
const height = Math.min(row.height, scroll.viewport.height)
|
||||
if (row.y + height > scroll.viewport.y + scroll.viewport.height)
|
||||
scroll.scrollBy(row.y + height - scroll.viewport.y - scroll.viewport.height)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
state().field
|
||||
state().selected
|
||||
size()
|
||||
revealChoice()
|
||||
})
|
||||
|
||||
const action = createMemo(() => {
|
||||
if (confirm()) return "submit"
|
||||
if (textual() || state().editing) return "save"
|
||||
const field = externalField()
|
||||
if (!field) return "choose"
|
||||
if (state().answers[field.key] === true) return formSingle(props.request) ? "submit" : "next"
|
||||
return state().externalReady[field.key] ? (size().width < 24 ? "done" : "acknowledge") : "open URL"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setState((previous) => formSync(previous, props.request))
|
||||
@@ -143,6 +177,12 @@ export function RunFormBody(props: {
|
||||
|
||||
const choose = (selected = state().selected) => {
|
||||
const base = formSetSelected(state(), selected)
|
||||
const row = choices.get(selected)
|
||||
if (scroll && row && (row.y < scroll.viewport.y || row.y >= scroll.viewport.y + scroll.viewport.height)) {
|
||||
setState(base)
|
||||
revealChoice()
|
||||
return
|
||||
}
|
||||
const next = formPick(base, props.request)
|
||||
setState(next)
|
||||
if (next.editing || multiple()) return
|
||||
@@ -208,6 +248,11 @@ export function RunFormBody(props: {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (unsupported()) return
|
||||
if (state().editing) return
|
||||
if (
|
||||
@@ -256,178 +301,346 @@ export function RunFormBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>{props.mono ? "*" : "◆"}</text>
|
||||
<text fg={props.theme.text}>{props.request.title}</text>
|
||||
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||
<text fg={props.theme.muted}>
|
||||
{confirm()
|
||||
? "Review"
|
||||
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={message()}>{(value) => <text fg={props.theme.muted}>{value()}</text>}</Show>
|
||||
<Show when={unsupported()}>
|
||||
{(value) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.warning} wrapMode="word">
|
||||
{value()}
|
||||
</text>
|
||||
<text fg={props.theme.muted}>This request remains pending until you dismiss it.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && externalField()}>
|
||||
{(field) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.text}>{field().description ?? formLabel(field())}</text>
|
||||
<text fg={props.theme.highlight} wrapMode="word">
|
||||
{field().url}
|
||||
</text>
|
||||
<text fg={props.theme.muted}>
|
||||
{state().answers[field().key] === true
|
||||
? "Acknowledged"
|
||||
: state().externalReady[field().key]
|
||||
? "Press enter to acknowledge completion"
|
||||
: "Press enter to open the URL"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && answerField() && !confirm()}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multiple() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
<Show when={textual() || state().editing}>
|
||||
<textarea
|
||||
ref={(item: TextareaRenderable) => {
|
||||
area = item
|
||||
}}
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
initialValue={formInput(state(), current())}
|
||||
placeholder={formPlaceholder(answerField())}
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused
|
||||
onSubmit={commitInput}
|
||||
onContentChange={() => {
|
||||
const currentArea = area
|
||||
if (!currentArea || currentArea.isDestroyed) return
|
||||
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
void cancel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!textual() && !state().editing}>
|
||||
<box flexDirection="column">
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const picked = () => {
|
||||
const field = current()
|
||||
if (!field) return false
|
||||
const value = state().answers[field.key]
|
||||
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
|
||||
}
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||
onMouseUp={() => choose(index())}
|
||||
>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>
|
||||
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
|
||||
</text>
|
||||
<text fg={active() ? props.theme.text : props.theme.muted}>
|
||||
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||
{row.label}
|
||||
{!multiple() && picked() ? " *" : ""}
|
||||
</text>
|
||||
<Show when={row.description}>
|
||||
<text fg={props.theme.muted}>{row.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
|
||||
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
|
||||
{props.mono
|
||||
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
|
||||
: `${rows().length + 1}.`}
|
||||
</text>
|
||||
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
|
||||
Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!unsupported() && confirm()}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.request.fields}>
|
||||
{(field) => (
|
||||
<text fg={props.theme.muted} wrapMode="none" truncate>
|
||||
{formLabel(field)}:{" "}
|
||||
{field.type === "external"
|
||||
? state().answers[field.key] === true
|
||||
? "acknowledged"
|
||||
: "required"
|
||||
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={props.theme.muted}>
|
||||
{state().submitting
|
||||
? "submitting..."
|
||||
: unsupported()
|
||||
? "esc dismiss"
|
||||
: confirm()
|
||||
? "enter submit esc dismiss"
|
||||
: textual() || state().editing
|
||||
? "enter save esc dismiss"
|
||||
: props.mono
|
||||
? "up/down select enter choose tab next esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme.surface}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<box height={1} flexDirection="row" gap={1} flexShrink={0} marginBottom={compact() ? 0 : 1}>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.question} wrapMode="none" flexShrink={0}>
|
||||
{props.mono ? "*" : "◆"}
|
||||
</text>
|
||||
<Show when={state().error}>
|
||||
<text fg={props.theme.error} wrapMode="none" truncate>
|
||||
{state().error}
|
||||
<text fg={props.theme.text} wrapMode="none" truncate minWidth={0}>
|
||||
{props.request.title}
|
||||
</text>
|
||||
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||
<text fg={props.theme.muted} wrapMode="none" flexShrink={0}>
|
||||
{confirm()
|
||||
? "Review"
|
||||
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height={!compact() && editing() ? Math.min(contentHeight(), Math.max(1, size().height - 9)) : undefined}
|
||||
flexGrow={!compact() && editing() ? 0 : 1}
|
||||
minHeight={0}
|
||||
viewportOptions={{
|
||||
paddingRight: props.mono ? 0 : 1,
|
||||
onSizeChange() {
|
||||
setViewportHeight(this.height)
|
||||
},
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
visible: !props.mono && contentHeight() > viewportHeight(),
|
||||
trackOptions: { backgroundColor: props.theme.surface, foregroundColor: props.theme.line },
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
gap={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setContentHeight(this.height)
|
||||
}}
|
||||
>
|
||||
<Show when={message()}>
|
||||
{(value) => (
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={unsupported()}>
|
||||
{(value) => (
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.warning} wrapMode="word" flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
This request remains pending until you dismiss it.
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && externalField()}>
|
||||
{(field) => (
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{field().description ?? formLabel(field())}
|
||||
</text>
|
||||
<text width="100%" fg={props.theme.link} wrapMode="word" flexShrink={0}>
|
||||
{field().url}
|
||||
</text>
|
||||
<text
|
||||
width="100%"
|
||||
fg={state().answers[field().key] === true ? props.theme.selection : props.theme.muted}
|
||||
flexShrink={0}
|
||||
>
|
||||
{state().answers[field().key] === true
|
||||
? "Acknowledged"
|
||||
: state().externalReady[field().key]
|
||||
? "Press enter to acknowledge completion"
|
||||
: "Press enter to open the URL"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && answerField() && !confirm()}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multiple() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
<Show when={!textual() && !state().editing}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0}>
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const picked = () => {
|
||||
const field = current()
|
||||
if (!field) return false
|
||||
const value = state().answers[field.key]
|
||||
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
|
||||
}
|
||||
const ordinal = () => (props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`)
|
||||
const inline = () =>
|
||||
!!row.description &&
|
||||
stringWidth(ordinal()) +
|
||||
2 +
|
||||
stringWidth(row.label) +
|
||||
(multiple() ? 4 : picked() ? 2 : 0) +
|
||||
stringWidth(row.description) <=
|
||||
size().width - (compact() ? 0 : 5) - (props.mono ? 0 : 1)
|
||||
return (
|
||||
<box
|
||||
ref={(item) => {
|
||||
choices.set(index(), item)
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="flex-start"
|
||||
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||
backgroundColor={active() ? props.theme.formfieldFocusedBg : "transparent"}
|
||||
onMouseUp={() => choose(index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{ordinal()}
|
||||
</text>
|
||||
<box
|
||||
flexDirection={inline() ? "row" : "column"}
|
||||
gap={inline() ? 1 : 0}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
>
|
||||
<text
|
||||
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
|
||||
wrapMode="word"
|
||||
flexShrink={0}
|
||||
>
|
||||
<span style={{ fg: picked() ? props.theme.selection : undefined }}>
|
||||
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||
</span>
|
||||
{row.label}
|
||||
<span style={{ fg: props.theme.selection }}>{!multiple() && picked() ? " *" : ""}</span>
|
||||
</text>
|
||||
<Show when={row.description}>
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{row.description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box
|
||||
ref={(item) => {
|
||||
choices.set(rows().length, item)
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={
|
||||
state().selected === rows().length ? props.theme.formfieldFocusedBg : "transparent"
|
||||
}
|
||||
onMouseUp={() => choose(rows().length)}
|
||||
>
|
||||
<text
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
fg={
|
||||
state().selected === rows().length
|
||||
? props.theme.formfieldFocusedText
|
||||
: props.theme.formfieldText
|
||||
}
|
||||
>
|
||||
{props.mono
|
||||
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
|
||||
: `${rows().length + 1}.`}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="word"
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
fg={
|
||||
state().selected === rows().length
|
||||
? props.theme.formfieldFocusedText
|
||||
: props.theme.formfieldText
|
||||
}
|
||||
>
|
||||
Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!unsupported() && confirm()}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0}>
|
||||
<For each={props.request.fields}>
|
||||
{(field) => (
|
||||
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{formLabel(field)}:{" "}
|
||||
{field.type === "external"
|
||||
? state().answers[field.key] === true
|
||||
? "acknowledged"
|
||||
: "required"
|
||||
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={stringWidth(state().error) > size().width || state().error.includes("\n")}>
|
||||
<text width="100%" fg={props.theme.error} wrapMode="word" flexShrink={0}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={!unsupported() && answerField() && editing()}>
|
||||
<textarea
|
||||
ref={(item: TextareaRenderable) => {
|
||||
area = item
|
||||
}}
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={Math.max(1, Math.min(3, size().height - 6))}
|
||||
flexShrink={0}
|
||||
marginTop={compact() ? 0 : 1}
|
||||
initialValue={formInput(state(), current())}
|
||||
placeholder={formPlaceholder(answerField())}
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.formfieldText}
|
||||
focusedTextColor={props.theme.formfieldFocusedText}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.formfieldFocusedBg}
|
||||
cursorColor={props.theme.formfieldFocusedText}
|
||||
focused
|
||||
onSubmit={commitInput}
|
||||
onContentChange={() => {
|
||||
const currentArea = area
|
||||
if (!currentArea || currentArea.isDestroyed) return
|
||||
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
void cancel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state().error && compact()}>
|
||||
<text height={1} fg={props.theme.error} wrapMode="none" truncate flexShrink={0}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!compact() && editing()}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</Show>
|
||||
<Show
|
||||
when={compact()}
|
||||
fallback={
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text
|
||||
fg={state().submitting ? props.theme.running : props.theme.muted}
|
||||
wrapMode="word"
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
>
|
||||
{state().submitting
|
||||
? "submitting..."
|
||||
: unsupported()
|
||||
? "esc dismiss"
|
||||
: confirm()
|
||||
? "enter submit esc dismiss"
|
||||
: editing()
|
||||
? "enter save esc dismiss"
|
||||
: externalField()
|
||||
? `enter ${action()} esc dismiss`
|
||||
: props.mono
|
||||
? "up/down select enter choose tab next esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
</text>
|
||||
<Show when={state().error}>
|
||||
<text fg={props.theme.error} wrapMode="none" truncate flexShrink={1}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<Show when={!unsupported()}>
|
||||
<text
|
||||
height={1}
|
||||
fg={state().submitting ? props.theme.running : props.theme.muted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{state().submitting ? "submitting..." : `enter ${action()}`}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!state().submitting}>
|
||||
<text height={1} fg={props.theme.muted} wrapMode="none" flexShrink={0}>
|
||||
esc dismiss
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!state().submitting && size().height >= 10}>
|
||||
<text fg={props.theme.muted} wrapMode="word" maxWidth="100%" flexShrink={0}>
|
||||
{size().width >= 56 && rows().length > 0 && !state().editing ? "up/down select tab next " : ""}pgup/pgdn
|
||||
scroll
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
|
||||
import { monoTruncate } from "./mono"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
export const FOOTER_COMPACT_WIDTH = 40
|
||||
|
||||
export function footerMenuText(text: string, width: number, mono = false) {
|
||||
if (!mono) return Locale.truncateWidth(text, width)
|
||||
if (stringWidth(text) <= width) return text
|
||||
const suffix = ".".repeat(Math.min(3, Math.max(0, width)))
|
||||
return Locale.takeWidth(text, width - suffix.length) + suffix
|
||||
}
|
||||
|
||||
export type RunFooterMenuItem = {
|
||||
display: string
|
||||
icon?: (color: ColorInput) => JSX.Element
|
||||
current?: boolean
|
||||
description?: string
|
||||
category?: string
|
||||
footer?: string
|
||||
footerTone?: "selection" | "running" | "error" | "success"
|
||||
}
|
||||
|
||||
type RunFooterMenuRow =
|
||||
@@ -22,10 +32,10 @@ type RunFooterMenuRow =
|
||||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number | Accessor<number> }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const limit = () => input.limit ?? FOOTER_MENU_ROWS
|
||||
const limit = () => Math.max(1, typeof input.limit === "function" ? input.limit() : (input.limit ?? FOOTER_MENU_ROWS))
|
||||
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
|
||||
|
||||
const reveal = (index: number) => {
|
||||
@@ -58,6 +68,7 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
||||
selected,
|
||||
offset,
|
||||
rows,
|
||||
limit,
|
||||
reveal,
|
||||
reset,
|
||||
move,
|
||||
@@ -76,13 +87,17 @@ export function RunFooterMenu(props: {
|
||||
paddingLeft?: number
|
||||
paddingRight?: number
|
||||
grouped?: boolean
|
||||
compact?: boolean
|
||||
background?: boolean
|
||||
headerColor?: ColorInput
|
||||
mono?: boolean
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
||||
const limit = () => Math.max(1, Math.min(props.rows(), props.limit ?? FOOTER_MENU_ROWS))
|
||||
const border = () => props.border ?? true
|
||||
const paddingLeft = () => Math.min(props.paddingLeft ?? 1, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
|
||||
const paddingRight = () => Math.min(props.paddingRight ?? 0, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
|
||||
const width = () => Math.max(0, term().width - (border() ? 1 : 0) - paddingLeft() - paddingRight())
|
||||
const [groupOffset, setGroupOffset] = createSignal(0)
|
||||
let previous = -1
|
||||
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
|
||||
@@ -90,12 +105,12 @@ export function RunFooterMenu(props: {
|
||||
let category = ""
|
||||
props.items().forEach((item, index) => {
|
||||
if (item.category && item.category !== category) {
|
||||
if (all.length > 0) {
|
||||
if (all.length > 0 && !props.compact) {
|
||||
all.push({ type: "spacer" })
|
||||
}
|
||||
|
||||
category = item.category
|
||||
all.push({ type: "header", label: item.category })
|
||||
if (!props.compact) all.push({ type: "header", label: item.category })
|
||||
}
|
||||
|
||||
all.push({ type: "item", item, index })
|
||||
@@ -151,34 +166,11 @@ export function RunFooterMenu(props: {
|
||||
)
|
||||
return width === 0 ? 0 : width + 2
|
||||
})
|
||||
const descriptionPad = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
|
||||
}
|
||||
const descriptionText = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return
|
||||
}
|
||||
|
||||
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
|
||||
const available =
|
||||
term().width -
|
||||
(border() ? 1 : 0) -
|
||||
(props.paddingLeft ?? 1) -
|
||||
(props.paddingRight ?? 0) -
|
||||
descriptionColumn() -
|
||||
footerWidth -
|
||||
4
|
||||
const width = Math.max(12, available)
|
||||
return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
height={props.rows()}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
flexDirection="column"
|
||||
>
|
||||
@@ -196,8 +188,8 @@ export function RunFooterMenu(props: {
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
paddingLeft={paddingLeft()}
|
||||
paddingRight={paddingRight()}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
>
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||
@@ -213,9 +205,9 @@ export function RunFooterMenu(props: {
|
||||
|
||||
if (row.type === "header") {
|
||||
return (
|
||||
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
|
||||
<box height={1} flexShrink={0} paddingLeft={paddingLeft()} paddingRight={paddingRight()}>
|
||||
<text
|
||||
fg={props.headerColor ?? props.theme().highlight}
|
||||
fg={props.headerColor ?? props.theme().muted}
|
||||
attributes={TextAttributes.BOLD}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
@@ -227,71 +219,87 @@ export function RunFooterMenu(props: {
|
||||
}
|
||||
|
||||
const active = () => row.index === props.selected()
|
||||
const available = () => Math.max(0, width() - (row.item.icon ? 2 : 0))
|
||||
const attributes = () =>
|
||||
active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined
|
||||
const background = () =>
|
||||
active()
|
||||
? props.background
|
||||
? props.theme().selected
|
||||
: props.theme().shade
|
||||
: props.background
|
||||
? props.theme().shade
|
||||
: transparent
|
||||
active() ? props.theme().actionFocusedBg : props.background ? props.theme().shade : transparent
|
||||
const footer = () => {
|
||||
if (!row.item.footer) return
|
||||
const title = stringWidth(row.item.display)
|
||||
const primary = row.item.footerTone && !(row.item.current && row.item.footerTone === "selection")
|
||||
return (primary ? Math.min(row.item.icon ? 4 : 8, title) : title) + 1 + stringWidth(row.item.footer) <=
|
||||
available()
|
||||
? row.item.footer
|
||||
: undefined
|
||||
}
|
||||
const description = () => {
|
||||
if (!row.item.description) return
|
||||
const remaining = available() - descriptionColumn() - (footer() ? stringWidth(footer()!) + 1 : 0)
|
||||
if (remaining < Math.min(12, stringWidth(row.item.description))) return
|
||||
return footerMenuText(row.item.description, remaining, props.mono)
|
||||
}
|
||||
return (
|
||||
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
<box height={1} flexShrink={0} paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
{border() ? (
|
||||
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
|
||||
<text fg={props.theme().actionFocusedText} bg={background()} wrapMode="none">
|
||||
{active() ? (props.mono ? ">" : "▌") : " "}
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
paddingLeft={paddingLeft()}
|
||||
paddingRight={paddingRight()}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
|
||||
{row.item.icon ? (
|
||||
<box width={2} flexShrink={0}>
|
||||
{row.item.icon(active() ? props.theme().actionFocusedText : props.theme().formfieldText)}
|
||||
</box>
|
||||
) : undefined}
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().text}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme().formfieldText}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.display}
|
||||
{footerMenuText(
|
||||
row.item.display,
|
||||
available() - (footer() ? stringWidth(footer()!) + 1 : 0),
|
||||
props.mono,
|
||||
)}
|
||||
</text>
|
||||
{row.item.description ? (
|
||||
{description() ? (
|
||||
<>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{descriptionPad(row.item)}
|
||||
{" ".repeat(Math.max(1, descriptionColumn() - stringWidth(row.item.display)))}
|
||||
</text>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{descriptionText(row.item)}
|
||||
{description()}
|
||||
</text>
|
||||
</>
|
||||
) : undefined}
|
||||
</box>
|
||||
{row.item.footer ? (
|
||||
{footer() ? (
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme()[row.item.footerTone ?? "muted"]}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.footer}
|
||||
{footer()}
|
||||
</text>
|
||||
) : undefined}
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
|
||||
import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
permissionShift,
|
||||
type PermissionOption,
|
||||
} from "./permission.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
@@ -44,13 +44,16 @@ function buttons(
|
||||
mono: boolean,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<For each={list}>
|
||||
{(option) => (
|
||||
<box
|
||||
width={stringWidth(permissionLabel(option)) + 2}
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === selected ? theme.highlight : transparent}
|
||||
backgroundColor={option === selected ? theme.actionFocusedBg : transparent}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) onHover(option)
|
||||
}}
|
||||
@@ -59,7 +62,8 @@ function buttons(
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={option === selected ? theme.surface : theme.muted}
|
||||
wrapMode="none"
|
||||
fg={option === selected ? theme.actionFocusedText : theme.actionSecondaryText}
|
||||
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
|
||||
>
|
||||
{permissionLabel(option)}
|
||||
@@ -108,11 +112,11 @@ export function RejectField(props: {
|
||||
wrapMode="word"
|
||||
placeholder="Tell OpenCode what to do differently"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
textColor={props.theme.formfieldText}
|
||||
focusedTextColor={props.theme.formfieldFocusedText}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focusedBackgroundColor={props.theme.formfieldFocusedBg}
|
||||
cursorColor={props.theme.formfieldFocusedText}
|
||||
focused={!props.disabled}
|
||||
onSubmit={props.onConfirm}
|
||||
onContentChange={() => {
|
||||
@@ -144,10 +148,14 @@ export function RunPermissionBody(props: {
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const width = () => size().width
|
||||
const compact = () => width() < 56 || size().height < 12
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request))
|
||||
const stage = createMemo(() => state().stage)
|
||||
const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const scrollbar = createMemo(() => ({
|
||||
visible: !props.mono,
|
||||
trackOptions: {
|
||||
@@ -156,19 +164,25 @@ export function RunPermissionBody(props: {
|
||||
},
|
||||
}))
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
permissionOptions(stage()).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
)
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const controlsWidth = () => opts().reduce((total, option) => total + stringWidth(permissionLabel(option)) + 3, -1)
|
||||
const hint = () =>
|
||||
compact() && width() < 56
|
||||
? "pgup/pgdn scroll"
|
||||
: `${props.mono ? "left/right" : "⇆"} select enter confirm esc ${stage() === "always" ? "cancel" : "reject"}`
|
||||
const inlineControls = () => controlsWidth() + stringWidth(hint()) + 1 <= width() - (compact() ? 0 : 5)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
if (stage() === "always") {
|
||||
return "Always allow"
|
||||
}
|
||||
|
||||
if (state().stage === "reject") {
|
||||
return "Reject permission"
|
||||
if (stage() === "reject") {
|
||||
return width() < 24 ? "Reject" : "Reject permission"
|
||||
}
|
||||
|
||||
return "Permission required"
|
||||
return width() < 24 ? "Permission" : "Permission required"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -184,6 +198,12 @@ export function RunPermissionBody(props: {
|
||||
setState((prev) => permissionShift(prev, dir, opts()))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
stage()
|
||||
props.request.id
|
||||
if (scroll && !scroll.isDestroyed) scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -233,6 +253,12 @@ export function RunPermissionBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.submitting) {
|
||||
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
|
||||
event.preventDefault()
|
||||
@@ -273,58 +299,50 @@ export function RunPermissionBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme.surface}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
gap={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
|
||||
{props.mono ? "!" : "△"}
|
||||
</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2}>
|
||||
<text fg={props.theme.muted} flexShrink={0}>
|
||||
{info().icon}
|
||||
</text>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state().stage === "reject"}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text height={1} fg={props.theme.text} wrapMode="none" truncate>
|
||||
<span style={{ fg: props.theme.permission }}>{props.mono ? "! " : "△ "}</span>
|
||||
{title()}
|
||||
</text>
|
||||
<Show when={!compact() && stage() === "reject"}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={state().stage !== "reject"}
|
||||
when={stage() !== "reject"}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
|
||||
<box width="100%" flexGrow={1} minHeight={0} justifyContent="flex-end">
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.line}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
flexDirection={width() >= 80 ? "row" : "column"}
|
||||
alignItems={width() >= 80 ? "center" : "stretch"}
|
||||
justifyContent="space-between"
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
gap={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
|
||||
<box width={width() >= 80 ? undefined : "100%"} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<RejectField
|
||||
theme={props.theme}
|
||||
text={state().message}
|
||||
@@ -342,17 +360,17 @@ export function RunPermissionBody(props: {
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
|
||||
{compact() ? "Waiting..." : "Waiting for permission event..."}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={compact() ? 1 : 2} flexShrink={0}>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{compact() ? "reject" : "confirm"}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{compact() ? "back" : "cancel"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -360,21 +378,46 @@ export function RunPermissionBody(props: {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
paddingLeft={compact() ? 0 : 1}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
viewportOptions={{
|
||||
paddingLeft: compact() ? 0 : 1,
|
||||
paddingRight: props.mono ? 0 : 1,
|
||||
}}
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={stage() === "permission"}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<box width="100%" paddingLeft={compact() ? 0 : 1} flexShrink={0}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
<span style={{ fg: props.theme.muted }}>{info().icon} </span>
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<Show
|
||||
when={info().patch}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
@@ -386,13 +429,16 @@ export function RunPermissionBody(props: {
|
||||
<Show
|
||||
when={props.block.syntax}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{patch()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(syntax) => (
|
||||
<code
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
wrapMode="word"
|
||||
filetype="diff"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
@@ -406,93 +452,107 @@ export function RunPermissionBody(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
<Show
|
||||
when={width() >= 40}
|
||||
fallback={
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{info().diff}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
No diff provided
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
width="100%"
|
||||
flexDirection={inlineControls() ? "row" : "column"}
|
||||
justifyContent="space-between"
|
||||
gap={compact() ? 0 : 1}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.pane}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
props.mono ?? false,
|
||||
)}
|
||||
<box width={inlineControls() ? controlsWidth() : "100%"} flexShrink={0}>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
props.mono ?? false,
|
||||
)}
|
||||
</box>
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
|
||||
{compact() ? "Waiting..." : "Waiting for permission event..."}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
<Show
|
||||
when={compact() && width() < 56}
|
||||
fallback={
|
||||
<>
|
||||
{props.mono ? "left/right" : "⇆"}
|
||||
<span style={{ fg: props.theme.muted }}>{" select "}</span>
|
||||
enter<span style={{ fg: props.theme.muted }}>{" confirm "}</span>
|
||||
esc<span style={{ fg: props.theme.muted }}> {stage() === "always" ? "cancel" : "reject"}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
pgup/pgdn<span style={{ fg: props.theme.muted }}> scroll</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -5,15 +5,38 @@
|
||||
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||
// composer while the footer view renders any active menus below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import {
|
||||
StyledText,
|
||||
decodePasteBytes,
|
||||
fg,
|
||||
stripAnsiSequences,
|
||||
type ColorInput,
|
||||
type KeyEvent,
|
||||
type PasteEvent,
|
||||
type TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { normalizePromptContent } from "../prompt/content"
|
||||
import { deduplicatePromptImages, promptAttachmentLabel } from "../prompt/attachment"
|
||||
import { resolvePastedAttachments } from "../component/prompt/local-attachment"
|
||||
import { createTuiClipboard, type OwnedClipboardService } from "../clipboard"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { errorMessage } from "../util/error"
|
||||
import {
|
||||
createPromptHistory,
|
||||
displayCharAt,
|
||||
@@ -30,8 +53,7 @@ import {
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { FOOTER_COMPACT_WIDTH, FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
@@ -44,13 +66,31 @@ import type {
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { EmptyBorder } from "../ui/border"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS
|
||||
|
||||
export function footerPromptLayout(
|
||||
height: number,
|
||||
lines = TEXTAREA_MIN_ROWS,
|
||||
options = 0,
|
||||
statusRows = 1,
|
||||
images = false,
|
||||
) {
|
||||
const padding = height >= PROMPT_MAX_ROWS + 4 ? 1 : 0
|
||||
// Reserve status and, where possible, one transcript row.
|
||||
const available = Math.max(1, height - 1 - statusRows - padding * 2)
|
||||
const preview = images && options === 0 ? Math.min(4, available - Math.min(TEXTAREA_MAX_ROWS, Math.max(1, lines))) : 0
|
||||
const imageRows = preview >= 3 ? preview : 0
|
||||
const textarea = Math.max(1, Math.min(TEXTAREA_MAX_ROWS, available - imageRows - (options > 0 ? 1 : 0)))
|
||||
const rows = Math.min(textarea, Math.max(1, lines))
|
||||
const menu = options > 0 ? Math.max(1, Math.min(AUTOCOMPLETE_ROWS, options, available - rows)) : 0
|
||||
return { padding, textarea, menu, images: imageRows, rows: rows + menu + imageRows }
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
|
||||
|
||||
@@ -86,8 +126,11 @@ type PromptInput = {
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
width: Accessor<number>
|
||||
statusRows: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
mono: Accessor<boolean>
|
||||
imagePreview?: boolean
|
||||
clipboard?: Pick<ClipboardService, "read">
|
||||
history?: Accessor<RunPrompt[]>
|
||||
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
onQueuedPromptSteer: (inboxID: string) => Promise<boolean>
|
||||
@@ -112,20 +155,20 @@ export type PromptState = {
|
||||
selected: Accessor<number>
|
||||
offset: Accessor<number>
|
||||
rows: Accessor<number>
|
||||
images: Accessor<ReadonlyArray<{ uri: string }>>
|
||||
layout: Accessor<ReturnType<typeof footerPromptLayout>>
|
||||
requestExit: () => boolean
|
||||
onSubmit: () => void
|
||||
submitText: (text: string) => void
|
||||
openEditor: (input?: { value?: string }) => Promise<void>
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
function clamp(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function emptyPrompt(shell: boolean): RunPrompt {
|
||||
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||
}
|
||||
@@ -177,11 +220,17 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
|
||||
export function RunPromptBody(props: {
|
||||
theme: () => RunFooterTheme
|
||||
background: () => ColorInput
|
||||
rail: () => ColorInput
|
||||
mono: boolean
|
||||
cursorStyle: RunTuiConfig["cursor"]
|
||||
placeholder: () => StyledText | string
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
images: Accessor<ReadonlyArray<{ uri: string }>>
|
||||
layout: Accessor<ReturnType<typeof footerPromptLayout>>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
@@ -227,12 +276,53 @@ export function RunPromptBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%">
|
||||
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
|
||||
<box width="100%" paddingTop={props.layout().padding} paddingBottom={props.layout().padding}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={props.rail()}
|
||||
customBorderChars={{ ...EmptyBorder, vertical: props.mono ? "|" : "┃" }}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
onSizeChange={props.onSizeChange}
|
||||
>
|
||||
<Show when={props.layout().images > 0}>
|
||||
<box width="100%" height={props.layout().images} flexDirection="row" gap={1}>
|
||||
<For
|
||||
each={props
|
||||
.images()
|
||||
.slice(0, 3)
|
||||
.map((image) => image.uri)}
|
||||
>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box width={props.layout().images * 2} height="100%" flexShrink={1}>
|
||||
<Show when={!failed()} fallback={<text fg={props.theme().muted}>No preview</text>}>
|
||||
<image
|
||||
id={`mini-prompt-image-${index()}`}
|
||||
source={image}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.images().length > 3}>
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||
+{props.images().length - 3} more
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<textarea
|
||||
width="100%"
|
||||
minHeight={TEXTAREA_MIN_ROWS}
|
||||
maxHeight={TEXTAREA_MAX_ROWS}
|
||||
maxHeight={props.layout().textarea}
|
||||
wrapMode="word"
|
||||
placeholder={props.placeholder()}
|
||||
placeholderColor={props.theme().muted}
|
||||
@@ -244,8 +334,8 @@ export function RunPromptBody(props: {
|
||||
cursorStyle={props.cursorStyle}
|
||||
onSubmit={props.onSubmit}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onPaste={() => {
|
||||
refreshPasteLayout()
|
||||
onPaste={(event) => {
|
||||
void props.onPaste(event).finally(refreshPasteLayout)
|
||||
}}
|
||||
onContentChange={props.onContentChange}
|
||||
ref={(next) => {
|
||||
@@ -258,6 +348,10 @@ export function RunPromptBody(props: {
|
||||
}
|
||||
|
||||
export function createPromptState(input: PromptInput): PromptState {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const [lines, setLines] = createSignal(TEXTAREA_MIN_ROWS)
|
||||
const [statusRows, setStatusRows] = createSignal(1)
|
||||
const [shell, setShell] = createSignal(false)
|
||||
const placeholder = createMemo(() => {
|
||||
if (shell()) {
|
||||
@@ -268,7 +362,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return ""
|
||||
}
|
||||
|
||||
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
|
||||
return new StyledText([
|
||||
fg(input.theme().muted)(`Ask anything, / for commands, @ for context${input.mono() ? "..." : "…"}`),
|
||||
])
|
||||
})
|
||||
|
||||
let history = createPromptHistory(input.history?.())
|
||||
@@ -283,6 +379,31 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
let type = 0
|
||||
let parts: Mention[] = []
|
||||
let marks = new Map<number, number>()
|
||||
const [draftParts, setDraftParts] = createSignal<RunPromptPart[]>([])
|
||||
const attachments = createMemo(() =>
|
||||
draftParts().flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
description: part.description,
|
||||
mention: part.source?.text
|
||||
? { start: part.source.text.start, end: part.source.text.end, text: part.source.text.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
const images = createMemo(() =>
|
||||
(deduplicatePromptImages(attachments()) ?? []).filter((file) => file.uri.startsWith("data:image/")),
|
||||
)
|
||||
let clipboard: OwnedClipboardService | undefined
|
||||
let pasteQueue: Promise<void> | undefined
|
||||
let applyingPaste = false
|
||||
let disposed = false
|
||||
let revision = 0
|
||||
|
||||
const [mode, setMode] = createSignal<MenuMode>(false)
|
||||
const [at, setAt] = createSignal(0)
|
||||
@@ -290,11 +411,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
const visible = createMemo(() => mode() !== false)
|
||||
|
||||
const setShellMode = (value: boolean) => {
|
||||
revision += 1
|
||||
setShell(value)
|
||||
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
|
||||
}
|
||||
|
||||
const width = createMemo(() => Math.max(20, input.width() - 8))
|
||||
const width = createMemo(() => Math.max(0, input.width() - (input.width() < FOOTER_COMPACT_WIDTH ? 2 : 4)))
|
||||
const agents = createMemo<Auto[]>(() => {
|
||||
return input
|
||||
.agents()
|
||||
@@ -317,9 +439,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
const references = createMemo<Auto[]>(() => {
|
||||
return input.references().map((item) => ({
|
||||
kind: "mention",
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + item.name, width(), true)
|
||||
: Locale.truncateMiddle("@" + item.name, width()),
|
||||
display: "@" + item.name,
|
||||
value: item.name,
|
||||
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
|
||||
part: {
|
||||
@@ -339,7 +459,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
}))
|
||||
})
|
||||
const [files] = createResource(
|
||||
const [fileResults] = createResource(
|
||||
query,
|
||||
async (value) => {
|
||||
if (!visible() || mode() !== "mention") {
|
||||
@@ -361,9 +481,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
return {
|
||||
kind: "mention",
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + filename, width(), true)
|
||||
: Locale.truncateMiddle("@" + filename, width()),
|
||||
display: "@" + filename,
|
||||
value: filename,
|
||||
directory: item.endsWith("/"),
|
||||
part: {
|
||||
@@ -386,6 +504,15 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
{ initialValue: [] as Auto[] },
|
||||
)
|
||||
const files = createMemo(() =>
|
||||
fileResults().map((item) => {
|
||||
const parts = item.value.split("/")
|
||||
const paths = parts
|
||||
.slice(0, item.directory ? -1 : undefined)
|
||||
.map((_, index) => "@" + parts.slice(index).join("/"))
|
||||
return { ...item, display: paths.find((value) => stringWidth(value) <= width()) ?? paths.at(-1) ?? item.display }
|
||||
}),
|
||||
)
|
||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
|
||||
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const skillOptions = createMemo<SkillOption[]>(() =>
|
||||
@@ -482,10 +609,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
})
|
||||
.map((item) => item.obj)
|
||||
})
|
||||
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
|
||||
const popup = createMemo(() => {
|
||||
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
|
||||
const layout = createMemo(() => {
|
||||
term()
|
||||
return footerPromptLayout(
|
||||
renderer.terminalHeight,
|
||||
lines(),
|
||||
visible() ? Math.max(1, options().length) : 0,
|
||||
statusRows(),
|
||||
input.imagePreview === true && !input.mono() && !shell() && images().length > 0,
|
||||
)
|
||||
})
|
||||
const menu = createFooterMenuState({ count: () => options().length, limit: () => Math.max(1, layout().menu) })
|
||||
|
||||
const hide = () => {
|
||||
setMode(false)
|
||||
@@ -498,7 +632,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
|
||||
setLines(Math.max(area.lineCount, area.virtualLineCount))
|
||||
input.onRows(layout().rows)
|
||||
}
|
||||
|
||||
const scheduleRows = () => {
|
||||
@@ -518,8 +653,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
const next: Mention[] = []
|
||||
const map = new Map<number, number>()
|
||||
const next = parts.map<Mention | undefined>((part) =>
|
||||
part.type === "file" && !part.source?.text ? part : undefined,
|
||||
)
|
||||
let tracked = 0
|
||||
for (const item of area.extmarks.getAllForTypeId(type)) {
|
||||
const idx = marks.get(item.id)
|
||||
if (idx === undefined) {
|
||||
@@ -556,15 +693,15 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
copy.source.text.value = text
|
||||
}
|
||||
|
||||
map.set(item.id, next.length)
|
||||
next.push(copy)
|
||||
tracked += 1
|
||||
next[idx] = copy
|
||||
}
|
||||
|
||||
const stale = map.size !== marks.size
|
||||
parts = next
|
||||
marks = map
|
||||
const retained = next.filter((part): part is Mention => part !== undefined)
|
||||
const stale = tracked !== marks.size || retained.length !== parts.length
|
||||
parts = retained
|
||||
if (stale) {
|
||||
restoreParts(next)
|
||||
restoreParts(retained)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +711,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
parts = []
|
||||
marks = new Map()
|
||||
setDraftParts([])
|
||||
}
|
||||
|
||||
const restoreParts = (value: RunPromptPart[]) => {
|
||||
@@ -581,6 +719,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
parts = value
|
||||
.filter((item): item is Mention => item.type === "file" || item.type === "agent" || item.type === "skill")
|
||||
.map((item) => structuredClone(item))
|
||||
setDraftParts(parts)
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
@@ -604,6 +743,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
revision += 1
|
||||
draft = promptCopy(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
@@ -619,6 +759,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const resetDraft = () => {
|
||||
revision += 1
|
||||
if (area && !area.isDestroyed) {
|
||||
area.setText("")
|
||||
}
|
||||
@@ -719,6 +860,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
syncParts()
|
||||
setDraftParts(parts)
|
||||
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
|
||||
draft = shell()
|
||||
? {
|
||||
@@ -733,6 +875,87 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
const pasteAttachment = (file: { uri: string; filename?: string }) => {
|
||||
if (!area || area.isDestroyed) return
|
||||
syncDraft()
|
||||
const value = promptAttachmentLabel(attachments(), { uri: file.uri, name: file.filename })
|
||||
area.insertText(value + " ")
|
||||
const end = area.cursorOffset - 1
|
||||
const start = end - stringWidth(value)
|
||||
const id = area.extmarks.create({ start, end, virtual: true, typeId: type })
|
||||
marks.set(id, parts.length)
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: file.uri,
|
||||
filename: file.filename,
|
||||
mime: file.uri.slice(5, file.uri.indexOf(";")),
|
||||
source: { type: "file", text: { start, end, value } },
|
||||
})
|
||||
syncDraft()
|
||||
}
|
||||
|
||||
const paste = (text?: string) => {
|
||||
const next = (pasteQueue ?? Promise.resolve())
|
||||
.then(async () => {
|
||||
const target = area
|
||||
if (disposed || !target || target.isDestroyed || !input.prompt()) return
|
||||
const before = revision
|
||||
const changed = () =>
|
||||
disposed || area !== target || target.isDestroyed || revision !== before || !input.prompt()
|
||||
const content =
|
||||
text === undefined
|
||||
? await (input.clipboard ?? (clipboard ??= createTuiClipboard(renderer))).read()
|
||||
: { mime: "text/plain", data: text }
|
||||
if (!content || changed()) return
|
||||
const image = content.mime.startsWith("image/")
|
||||
if (image && shell()) {
|
||||
input.onStatus("image attachments are unavailable in shell mode")
|
||||
return
|
||||
}
|
||||
if (!image && content.mime !== "text/plain") return
|
||||
const normalized = image ? content.data : stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
|
||||
const files = image
|
||||
? [{ type: "file" as const, uri: `data:${content.mime};base64,${content.data}`, filename: "clipboard" }]
|
||||
: shell()
|
||||
? undefined
|
||||
: await resolvePastedAttachments(normalized, process.platform)
|
||||
if (changed()) return
|
||||
// A paste's own text edits must not cancel a submit waiting on that paste.
|
||||
applyingPaste = true
|
||||
try {
|
||||
files?.forEach((file) => {
|
||||
if (file.type === "file") {
|
||||
pasteAttachment(file)
|
||||
return
|
||||
}
|
||||
target.insertText(file.content)
|
||||
})
|
||||
if (!files) target.insertText(normalized)
|
||||
} finally {
|
||||
applyingPaste = false
|
||||
}
|
||||
hide()
|
||||
syncDraft()
|
||||
target.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
scheduleRows()
|
||||
})
|
||||
.catch((error) => {
|
||||
revision += 1
|
||||
if (!disposed) input.onStatus(errorMessage(error))
|
||||
})
|
||||
.finally(() => {
|
||||
if (pasteQueue === next) pasteQueue = undefined
|
||||
})
|
||||
pasteQueue = next
|
||||
return next
|
||||
}
|
||||
|
||||
const onPaste = (event: PasteEvent) => {
|
||||
event.preventDefault()
|
||||
return paste(event.bytes.length ? decodePasteBytes(event.bytes) : undefined)
|
||||
}
|
||||
|
||||
const push = (value: RunPrompt) => {
|
||||
history = pushPromptHistory(history, value)
|
||||
}
|
||||
@@ -788,7 +1011,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
const requestExit = () => {
|
||||
const text = area && !area.isDestroyed ? area.plainText : draft.text
|
||||
if (input.prompt() && text.length > 0) {
|
||||
revision += 1
|
||||
if (input.prompt() && (text.length > 0 || draft.parts.some((part) => part.type === "file"))) {
|
||||
input.onInputClear()
|
||||
resetDraft()
|
||||
return true
|
||||
@@ -1033,6 +1257,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: input.prompt(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.paste",
|
||||
title: "Paste",
|
||||
group: "Prompt",
|
||||
run: () => paste(),
|
||||
},
|
||||
{
|
||||
id: "session.interrupt",
|
||||
title: "Interrupt session",
|
||||
@@ -1055,8 +1285,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
group: "Prompt",
|
||||
palette: true,
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
onSubmit("queue")
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1217,7 +1446,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
if (submitting) return
|
||||
|
||||
if (!next.text.trim()) {
|
||||
if (!next.text.trim() && !next.parts.some((part) => part.type === "file")) {
|
||||
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||
if (queued) {
|
||||
submitting = true
|
||||
@@ -1289,9 +1518,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = (delivery: RunDelivery = "steer") => {
|
||||
if (pasteQueue) {
|
||||
const before = revision
|
||||
void pasteQueue.then(() => {
|
||||
if (revision === before) onSubmit(delivery)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (disposed || !input.prompt()) return
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft))
|
||||
submitPrompt(promptCopy(draft), delivery)
|
||||
}
|
||||
|
||||
const submitText = (text: string) => {
|
||||
@@ -1299,14 +1536,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
void clipboard?.dispose().catch(() => {})
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setStatusRows(input.statusRows())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.width()
|
||||
popup()
|
||||
layout()
|
||||
if (input.prompt()) {
|
||||
scheduleRows()
|
||||
}
|
||||
@@ -1361,17 +1604,22 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
selected: menu.selected,
|
||||
offset: menu.offset,
|
||||
rows: menu.rows,
|
||||
images,
|
||||
layout,
|
||||
requestExit,
|
||||
onSubmit,
|
||||
onSubmit: () => onSubmit(),
|
||||
submitText,
|
||||
openEditor,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onContentChange: () => {
|
||||
if (!applyingPaste && area && area.plainText !== draft.text) revision += 1
|
||||
input.onInputClear()
|
||||
syncDraft()
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
onSizeChange: scheduleRows,
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "../component/register-spinner"
|
||||
import { Show, createMemo, indexArray } from "solid-js"
|
||||
import { Show, createMemo, createSignal, indexArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../component/spinner-frames"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { footerMenuText } from "./footer.menu"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -14,7 +16,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 14
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.highlight
|
||||
return theme.success
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
@@ -25,7 +27,7 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
|
||||
return theme.error
|
||||
}
|
||||
|
||||
return theme.highlight
|
||||
return theme.running
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
|
||||
@@ -59,6 +61,10 @@ export function RunFooterSubagentBody(props: {
|
||||
shellOutput?: () => boolean
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const width = () => size().width
|
||||
const compact = () => width() < 56 || size().height < 12
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const tab = createMemo(() => props.tab())
|
||||
@@ -102,6 +108,11 @@ export function RunFooterSubagentBody(props: {
|
||||
if (tab()?.status !== "running") return undefined
|
||||
return props.interrupt?.()
|
||||
})
|
||||
const count = () => (props.total() > 1 && props.index() > 0 ? `${props.index()} of ${props.total()}` : "")
|
||||
const headerControlsWidth = () =>
|
||||
(interruptHint() ? stringWidth(`${interruptHint()} interrupt`) + 1 : 0) + (count() ? stringWidth(count()) + 1 : 0)
|
||||
const headerControls = () => !compact() && stringWidth(title()) + 2 + headerControlsWidth() <= width() - 4
|
||||
const titleWidth = () => Math.max(1, width() - (compact() ? 2 : 6) - (headerControls() ? headerControlsWidth() : 0))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
@@ -129,70 +140,130 @@ export function RunFooterSubagentBody(props: {
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={footer().surface}>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<Show when={tab()}>
|
||||
{(current) => (
|
||||
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner
|
||||
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
|
||||
interval={props.mono ? 160 : 80}
|
||||
color={statusColor(footer(), current().status)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status, props.mono ?? false)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{title()}
|
||||
<Show when={subtitle().length > 0}>
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={footer().surface}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingLeft={compact() ? 0 : 1}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<Show when={tab()}>
|
||||
{(current) => (
|
||||
<box
|
||||
width="100%"
|
||||
height={compact() ? 1 : 2}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner
|
||||
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
|
||||
interval={props.mono ? 160 : 80}
|
||||
color={statusColor(footer(), current().status)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status, props.mono ?? false)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" flexGrow={1} flexShrink={1}>
|
||||
{footerMenuText(title(), titleWidth(), props.mono)}
|
||||
<Show when={subtitle().length > 0 && titleWidth() >= stringWidth(title()) + stringWidth(subtitle()) + 2}>
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={headerControls()}>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
{hint()} interrupt
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.index()} of {props.total()}
|
||||
</text>
|
||||
<Show when={count()}>
|
||||
{(value) => (
|
||||
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
viewportOptions={{ paddingRight: props.mono ? 0 : 1 }}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0} flexShrink={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text width="100%" fg={footer().muted} wrapMode="word" flexShrink={0}>
|
||||
No subagent activity yet
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text fg={footer().muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={!headerControls()}>
|
||||
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<text height={1} fg={footer().actionSecondaryText} wrapMode="none" flexShrink={0} onMouseUp={props.onClose}>
|
||||
esc back
|
||||
</text>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text maxWidth="100%" fg={footer().actionSecondaryText} wrapMode="word" flexShrink={0}>
|
||||
{hint()} {width() >= stringWidth(hint()) + 10 ? "interrupt" : "stop"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={width() >= 56}>
|
||||
<text height={1} fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
pgup/pgdn scroll
|
||||
</text>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text
|
||||
height={1}
|
||||
fg={footer().actionSecondaryText}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
onMouseUp={() => props.onCycle(1)}
|
||||
>
|
||||
tab next {props.index()}/{props.total()}
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
+136
-48
@@ -24,19 +24,22 @@
|
||||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
||||
// two-press pattern where the first press shows a hint and the second press
|
||||
// within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { CliRenderEvents, type CliRenderer, type CliRendererExternalOutputEvent } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { batch, createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||
import { RUN_SUBAGENT_PANEL_ROWS, footerPanelLayout } from "./footer.command"
|
||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { TEXTAREA_MIN_ROWS, footerPromptLayout } from "./footer.prompt"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { monoSnapshot } from "./mono"
|
||||
import { RunScrollbackStream } from "./scrollback.surface"
|
||||
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
|
||||
import { resolveRunTheme, type RunTheme } from "./theme"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { entrySplash } from "./splash"
|
||||
import { SEED_LAUNCH } from "../ui/one-cell-motion"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
@@ -75,6 +78,7 @@ type RunFooterOptions = {
|
||||
agents: RunAgent[]
|
||||
references: RunReference[]
|
||||
wrote?: boolean
|
||||
startup?: { version: string; detail: string }
|
||||
agent: string | undefined
|
||||
modelLabel: string
|
||||
model: RunInput["model"]
|
||||
@@ -82,12 +86,12 @@ type RunFooterOptions = {
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
mono: boolean
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: {
|
||||
current: MiniSettings
|
||||
update?: (change: MiniSettingChange) => Promise<MiniSettings>
|
||||
}
|
||||
onMonoChange?: (mono: boolean) => void
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
@@ -208,6 +212,9 @@ export class RunFooter implements FooterApi {
|
||||
private noticeTimeout: NodeJS.Timeout | undefined
|
||||
private turnAgent: string | undefined
|
||||
private requestExitHandler: (() => boolean) | undefined
|
||||
private startup: Accessor<RunFooterOptions["startup"]>
|
||||
private setStartup: Setter<RunFooterOptions["startup"]>
|
||||
private startupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private scrollback: RunScrollbackStream
|
||||
private themes: RunTheme[]
|
||||
private paletteRefreshRunning = false
|
||||
@@ -225,7 +232,8 @@ export class RunFooter implements FooterApi {
|
||||
.finally(() => this.destroyTheme(theme))
|
||||
},
|
||||
shellOutput: () => this.miniSettings().shell_output === "show",
|
||||
mono: this.options.mono,
|
||||
mono: this.miniSettings().mono,
|
||||
imagePreview: this.options.tuiConfig.session?.image_preview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,7 +246,7 @@ export class RunFooter implements FooterApi {
|
||||
status: "",
|
||||
notice: "",
|
||||
model: options.modelLabel,
|
||||
usage: "",
|
||||
usage: undefined,
|
||||
first: options.first,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
@@ -300,16 +308,19 @@ export class RunFooter implements FooterApi {
|
||||
const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current)
|
||||
this.miniSettings = miniSettings
|
||||
this.setMiniSettings = setMiniSettings
|
||||
const [startup, setStartup] = createSignal(options.startup)
|
||||
this.startup = startup
|
||||
this.setStartup = setStartup
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
if (!options.mono) {
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
}
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
|
||||
const footer = this
|
||||
void render(
|
||||
@@ -320,6 +331,7 @@ export class RunFooter implements FooterApi {
|
||||
return createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: footer.state,
|
||||
startup: footer.startup,
|
||||
view: footer.view,
|
||||
subagent: footer.subagent,
|
||||
queuedPrompts: footer.queuedPrompts,
|
||||
@@ -330,13 +342,14 @@ export class RunFooter implements FooterApi {
|
||||
providers: footer.providers,
|
||||
currentAgent: footer.currentAgent,
|
||||
currentAgentID: footer.currentAgentID,
|
||||
currentAgentExplicit: () => selectedAgentID() !== undefined,
|
||||
currentModel: footer.currentModel,
|
||||
variants: footer.variants,
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: footer.theme,
|
||||
tuiConfig: options.tuiConfig,
|
||||
mono: options.mono,
|
||||
get mono() {
|
||||
return footer.miniSettings().mono
|
||||
},
|
||||
miniSettings: footer.miniSettings,
|
||||
history: footer.history,
|
||||
onSubmit: footer.handlePrompt,
|
||||
@@ -347,7 +360,10 @@ export class RunFooter implements FooterApi {
|
||||
onInterrupt: footer.handleInterrupt,
|
||||
onBackground: options.onBackground,
|
||||
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||
onEditorOpen: options.onEditorOpen,
|
||||
onEditorOpen: (input) => {
|
||||
footer.finishStartup()
|
||||
return options.onEditorOpen(input)
|
||||
},
|
||||
onInputClear: footer.handleInputClear,
|
||||
onExitRequest: footer.handleExit,
|
||||
onRequestExit: footer.setRequestExitHandler,
|
||||
@@ -370,6 +386,26 @@ export class RunFooter implements FooterApi {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
if (options.startup)
|
||||
this.startupTimer = setTimeout(() => this.finishStartup(), (SEED_LAUNCH.frames.length - 1) * SEED_LAUNCH.interval)
|
||||
}
|
||||
|
||||
public finishStartup(): void {
|
||||
const startup = this.startup()
|
||||
if (!startup) return
|
||||
clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
this.setStartup(undefined)
|
||||
if (this.isGone) return
|
||||
this.applyHeight()
|
||||
this.renderer.writeToScrollback(
|
||||
entrySplash({
|
||||
...startup,
|
||||
theme: this.theme().splash,
|
||||
mono: this.miniSettings().mono,
|
||||
}),
|
||||
)
|
||||
this.renderer.requestRender()
|
||||
}
|
||||
|
||||
public get isClosed(): boolean {
|
||||
@@ -415,6 +451,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
this.finishStartup()
|
||||
const agent = this.turnAgent ?? this.currentAgent()
|
||||
this.turnAgent = undefined
|
||||
if (this.miniSettings().turn_summary === "hide") return
|
||||
@@ -516,7 +553,7 @@ export class RunFooter implements FooterApi {
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
notice: typeof next.notice === "string" ? next.notice : prev.notice,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
usage: "usage" in next ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
interrupt:
|
||||
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
|
||||
@@ -551,6 +588,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if (view.type !== "prompt") this.finishStartup()
|
||||
this.setView(view)
|
||||
this.applyHeight()
|
||||
}
|
||||
@@ -564,6 +602,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
const last = this.queue.at(-1)
|
||||
const merged = last ? coalesceProgressCommit(last, commit) : undefined
|
||||
if (merged) this.queue[this.queue.length - 1] = merged
|
||||
@@ -614,6 +653,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
this.scrollback.destroy()
|
||||
this.scrollback = this.createScrollback(wrote)
|
||||
}
|
||||
@@ -641,6 +681,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
this.flush()
|
||||
this.notifyClose()
|
||||
}
|
||||
@@ -693,23 +734,34 @@ export class RunFooter implements FooterApi {
|
||||
this.patch({ interrupt: 0, exit: 0 })
|
||||
}
|
||||
|
||||
// Resizes the footer to fit the current view. Permission and form views
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private handleResize = (): void => {
|
||||
if (!this.isGone) this.applyHeight()
|
||||
}
|
||||
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const route = this.promptRoute.type
|
||||
const height =
|
||||
const panel = footerPanelLayout(this.renderer.terminalHeight)
|
||||
const prompt = footerPromptLayout(this.renderer.terminalHeight)
|
||||
const desired =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "form"
|
||||
? this.base + FORM_ROWS
|
||||
: ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
|
||||
? 1 + RUN_COMMAND_PANEL_ROWS
|
||||
? 1 + panel.frame + panel.limit
|
||||
: route === "queued-menu" || route === "subagent-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: route === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
|
||||
: prompt.padding * 2 + 1 + this.rows
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
desired + (this.startup() ? 2 : 0),
|
||||
this.renderer.terminalHeight - (type === "prompt" && route === "composer" ? 1 : 0),
|
||||
),
|
||||
)
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
@@ -721,7 +773,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, value)
|
||||
if (rows === this.rows) {
|
||||
return
|
||||
}
|
||||
@@ -733,6 +785,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number }): void => {
|
||||
if (next.route.type !== "composer") this.finishStartup()
|
||||
this.promptRoute = next.route
|
||||
this.subagentMenuRows = next.subagentRows
|
||||
if (this.view().type === "prompt") {
|
||||
@@ -861,8 +914,29 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
try {
|
||||
this.setMiniSettings(await this.options.miniSettings.update(change))
|
||||
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
|
||||
const settings = await this.options.miniSettings.update(change)
|
||||
if (this.isClosed) return
|
||||
if (settings.mono === this.miniSettings().mono) {
|
||||
this.setMiniSettings(settings)
|
||||
this.setNotice("settings updated")
|
||||
return
|
||||
}
|
||||
const theme = await resolveRunTheme(this.renderer, this.options.tuiConfig.theme, settings.mono)
|
||||
this.flush()
|
||||
this.flushing = this.flushing.then(async () => {
|
||||
if (this.isClosed) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
await this.scrollback.setMono(settings.mono)
|
||||
batch(() => {
|
||||
this.setMiniSettings(settings)
|
||||
this.applyTheme(theme)
|
||||
this.options.onMonoChange?.(settings.mono)
|
||||
})
|
||||
})
|
||||
await this.flushing
|
||||
this.setNotice("settings updated")
|
||||
} catch (error) {
|
||||
this.setNotice("failed to save settings")
|
||||
throw error
|
||||
@@ -959,26 +1033,34 @@ export class RunFooter implements FooterApi {
|
||||
return true
|
||||
}
|
||||
|
||||
private handlePalette = (): void => {
|
||||
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
private applyTheme(theme: RunTheme): void {
|
||||
if (theme === this.theme()) return
|
||||
this.themes.push(theme)
|
||||
this.setTheme(theme)
|
||||
this.renderer.setBackgroundColor(theme.background)
|
||||
this.scrollback.setTheme(theme)
|
||||
}
|
||||
|
||||
// Keep the last known good theme when a runtime OSC probe times out.
|
||||
if (theme === RUN_THEME_FALLBACK) {
|
||||
return
|
||||
}
|
||||
private handleExternalOutput = (event: CliRendererExternalOutputEvent): void => {
|
||||
if (this.miniSettings().mono) monoSnapshot(event)
|
||||
}
|
||||
|
||||
this.themes.push(theme)
|
||||
this.setTheme(theme)
|
||||
this.renderer.setBackgroundColor(theme.background)
|
||||
private handlePalette = (): Promise<void> | undefined => {
|
||||
if (this.isGone || this.paletteRefreshRunning) return
|
||||
const mono = this.miniSettings().mono
|
||||
return resolveRunTheme(this.renderer, this.options.tuiConfig.theme, mono).then((theme) => {
|
||||
this.flushing = this.flushing
|
||||
.then(() => this.scrollback.setTheme(theme))
|
||||
.then(() => {
|
||||
if (this.isGone || mono !== this.miniSettings().mono) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
this.applyTheme(theme)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.flushError = error
|
||||
})
|
||||
return this.flushing
|
||||
})
|
||||
}
|
||||
|
||||
@@ -993,10 +1075,11 @@ export class RunFooter implements FooterApi {
|
||||
return false
|
||||
}
|
||||
|
||||
private handleThemeRefresh = (): void => {
|
||||
if (this.isGone || this.options.mono) {
|
||||
private handleThemeRefresh = (): Promise<void> | undefined => {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
if (this.miniSettings().mono) return this.handlePalette()
|
||||
|
||||
if (this.paletteRefreshRunning) {
|
||||
this.paletteRefreshQueued = true
|
||||
@@ -1006,22 +1089,23 @@ export class RunFooter implements FooterApi {
|
||||
this.paletteRefreshRunning = true
|
||||
const retry = this.renderer.paletteDetectionStatus === "detecting"
|
||||
this.renderer.clearPaletteCache()
|
||||
void this.renderer
|
||||
return this.renderer
|
||||
.getPalette({ size: 256 })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
.then(() => {
|
||||
this.paletteRefreshRunning = false
|
||||
if (!retry && !this.paletteRefreshQueued) {
|
||||
return
|
||||
// Theme files can change without a new terminal palette.
|
||||
return this.handlePalette()
|
||||
}
|
||||
|
||||
this.paletteRefreshQueued = false
|
||||
this.handleThemeRefresh()
|
||||
return this.handleThemeRefresh()
|
||||
})
|
||||
}
|
||||
|
||||
public refreshTheme(): void {
|
||||
this.handleThemeRefresh()
|
||||
public refreshTheme() {
|
||||
return this.handleThemeRefresh()
|
||||
}
|
||||
|
||||
private handleThemeSignal = (): void => {
|
||||
@@ -1039,6 +1123,8 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
this.flush()
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
@@ -1046,6 +1132,8 @@ export class RunFooter implements FooterApi {
|
||||
this.clearExitTimer()
|
||||
this.clearNoticeTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.renderer.off(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
|
||||
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.removeInputHandler(this.handleThemeNotification)
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextBuffer, TextBufferView } from "@opentui/core"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { registerOpencodeSpinner } from "../component/register-spinner"
|
||||
import { createColors, createFrames } from "../ui/spinner"
|
||||
import { OneCellSpinner } from "../component/one-cell-spinner"
|
||||
import { WORK_SPINNERS, SEED_LAUNCH, SEED_MONO } from "../ui/one-cell-motion"
|
||||
import { entrySplashLayout } from "./splash"
|
||||
import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunAgentSelectBody,
|
||||
@@ -31,9 +33,11 @@ import { RunFormBody } from "./footer.form"
|
||||
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
import { footerStatuslinePolicy } from "./footer.width"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { formatContextUsage } from "../util/session"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSingleFlight } from "../util/single-flight"
|
||||
|
||||
@@ -59,7 +63,7 @@ import type {
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
@@ -84,11 +88,11 @@ type RunFooterViewProps = {
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentAgent: () => string
|
||||
currentAgentID: () => string | undefined
|
||||
currentAgentExplicit: () => boolean
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
state: () => FooterState
|
||||
startup?: () => { version: string; detail: string } | undefined
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
@@ -97,6 +101,7 @@ type RunFooterViewProps = {
|
||||
mono: boolean
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
clipboard?: Pick<ClipboardService, "read">
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
@@ -122,8 +127,13 @@ type RunFooterViewProps = {
|
||||
}
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const width = createMemo(() => term().width)
|
||||
const startup = createMemo(() => {
|
||||
const value = props.startup?.()
|
||||
return value ? entrySplashLayout({ ...value, width: width(), mono: props.mono }) : undefined
|
||||
})
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
@@ -181,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
|
||||
const model = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
return current ? modelInfo(props.providers(), current).model : undefined
|
||||
return current ? modelInfo(props.providers(), current) : undefined
|
||||
})
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
@@ -198,10 +208,18 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
|
||||
const clearShortcut = () => shortcut("prompt.clear")
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const started = createMemo(() => (busy() ? performance.now() : undefined))
|
||||
const statusWidth = createMemo(() => Math.max(1, width() - (busy() ? 2 : 0)))
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const footerDetails = createMemo(() => props.miniSettings().footer === "show")
|
||||
const contextUsage = createMemo(() => {
|
||||
const current = usage()
|
||||
return current && current.tokens > 0 ? formatContextUsage(current.tokens, current.percent) : ""
|
||||
})
|
||||
const cost = createMemo(() => (usage()?.cost ? money.format(usage()!.cost!) : ""))
|
||||
const takeover = createMemo(() => exiting() || (busy() && armed()) || !!props.state().notice.trim())
|
||||
const footerDetails = createMemo(() => props.miniSettings().footer === "show" && !takeover())
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
return
|
||||
@@ -211,31 +229,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
const runTheme = createMemo(() => props.theme())
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
if (props.mono) {
|
||||
return {
|
||||
frames: ["-", "\\", "|", "/"],
|
||||
color: theme().text,
|
||||
}
|
||||
}
|
||||
const options = {
|
||||
color: theme().highlight,
|
||||
style: "blocks" as const,
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}
|
||||
return {
|
||||
frames: createFrames(options),
|
||||
color: createColors(options),
|
||||
}
|
||||
const agentColor = createMemo(() => {
|
||||
const colors = theme().categorical
|
||||
const index = props
|
||||
.agents()
|
||||
.filter((agent) => !agent.hidden)
|
||||
.findIndex((agent) => agent.id === props.currentAgentID())
|
||||
return colors[Math.max(0, index) % colors.length]!
|
||||
})
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const footerStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
const current = model()?.model ?? props.state().model.trim()
|
||||
const variant = props.currentVariant()
|
||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||
if (contextUsage()) details.push(contextUsage())
|
||||
if (cost()) details.push(cost())
|
||||
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
@@ -363,6 +372,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
openTab(next.sessionID)
|
||||
}
|
||||
const [promptRows, setPromptRows] = createSignal(1)
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
@@ -373,8 +383,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
view: promptView,
|
||||
prompt,
|
||||
width,
|
||||
statusRows: () => (menu() ? 0 : statusRows()),
|
||||
theme,
|
||||
mono: () => props.mono,
|
||||
imagePreview: props.tuiConfig.prompt?.image_preview,
|
||||
clipboard: props.clipboard,
|
||||
history: props.history,
|
||||
queuedPrompts: queue,
|
||||
onQueuedPromptSteer: (inboxID) => queuedPromptAction("steer", inboxID),
|
||||
@@ -387,63 +400,44 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onExit: props.onExit,
|
||||
onSkillMenu: openSkillMenu,
|
||||
onSettings: openSettings,
|
||||
onRows: props.onRows,
|
||||
onRows: setPromptRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const shell = createMemo(() => prompt() && composer.shell())
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
const stateStatus = createMemo(() => props.state().status.trim())
|
||||
const notice = createMemo(() => props.state().notice.trim())
|
||||
const modeLabel = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return "EXIT"
|
||||
}
|
||||
|
||||
return shell() ? "SHELL" : undefined
|
||||
})
|
||||
const modeColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return theme().error
|
||||
}
|
||||
|
||||
if (shell()) {
|
||||
return theme().warning
|
||||
}
|
||||
|
||||
return theme().highlight
|
||||
})
|
||||
const statusText = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
|
||||
if (exiting() || (busy() && armed())) {
|
||||
const key = exiting() ? clearShortcut() : interruptLabel()
|
||||
const action = exiting() ? "exit" : "stop"
|
||||
if (!key) return exiting() ? "Exit pending" : "Stop pending"
|
||||
const phrases = [
|
||||
`Press ${key} again to ${exiting() ? "exit" : "interrupt"}`,
|
||||
`${key} again to ${exiting() ? "exit" : "interrupt"}`,
|
||||
`${key} again: ${action}`,
|
||||
`${key} ${action}`,
|
||||
]
|
||||
return phrases.find((text) => stringWidth(text) <= statusWidth()) ?? phrases[phrases.length - 1]!
|
||||
}
|
||||
|
||||
if (busy() && armed()) return "again to interrupt"
|
||||
|
||||
if (notice()) return notice()
|
||||
|
||||
if (!footerDetails()) return shell() ? "Shell mode" : ""
|
||||
|
||||
if (busy()) return "interrupt"
|
||||
|
||||
if (stateStatus().length > 0) {
|
||||
return stateStatus()
|
||||
if (!footerDetails()) return shell() ? "Shell" : ""
|
||||
if (busy()) {
|
||||
return interruptLabel() ? `${interruptLabel()} stop` : "Running"
|
||||
}
|
||||
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!footerDetails()) return ""
|
||||
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
|
||||
return stateStatus() || (shell() ? "Shell" : "")
|
||||
})
|
||||
const agentStatus = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
|
||||
if (!footerDetails() || !prompt() || shell()) return undefined
|
||||
return props.currentAgent()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
const current = model()?.model ?? props.state().model.trim()
|
||||
if (!footerDetails() || !prompt() || shell() || !current) return
|
||||
return {
|
||||
model: current,
|
||||
provider: model()?.provider,
|
||||
variant: props.currentVariant(),
|
||||
}
|
||||
})
|
||||
@@ -453,7 +447,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
if (armed()) {
|
||||
return theme().highlight
|
||||
return theme().warning
|
||||
}
|
||||
|
||||
if (busy() || notice().length > 0 || stateStatus().length > 0) {
|
||||
@@ -462,79 +456,87 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
return theme().muted
|
||||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const contextHintCandidates = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: Array<{ key: string; label: string }> = []
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
const items: Array<{ id: "queued" | "subagents" | "background"; key: string; label: string; expanded?: string }> =
|
||||
[]
|
||||
if (queue().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
items.push({ id: "queued", key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
items.push({
|
||||
id: "subagents",
|
||||
key: subagentShortcut(),
|
||||
label: `${activeTabs().length} sub`,
|
||||
expanded: `${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`,
|
||||
})
|
||||
}
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ id: "background", key: backgroundShortcut(), label: "bg", expanded: "background" })
|
||||
}
|
||||
return items
|
||||
})
|
||||
const commandHint = createMemo(() => {
|
||||
if (!prompt()) return
|
||||
|
||||
if (shell()) {
|
||||
return { key: "esc", label: "normal" }
|
||||
}
|
||||
|
||||
if (!prompt() || takeover() || shell()) return
|
||||
if (command()) {
|
||||
return { key: command(), label: "cmd" }
|
||||
return { key: command(), label: "menu" }
|
||||
}
|
||||
})
|
||||
const commandHintWidth = createMemo(() => {
|
||||
const hint = commandHint()
|
||||
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
|
||||
})
|
||||
const statuslineText = createMemo(() =>
|
||||
busy() && !exiting() && (footerDetails() || armed())
|
||||
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
|
||||
: statusText(),
|
||||
)
|
||||
const statuslineMainWidth = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
|
||||
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
|
||||
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
|
||||
})
|
||||
const visibleModeLabel = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
|
||||
return mode
|
||||
})
|
||||
const statuslineMainAvailable = createMemo(() => {
|
||||
const mode = visibleModeLabel()
|
||||
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
|
||||
})
|
||||
const statuslineLayout = createMemo(() => {
|
||||
const agent = agentStatus()
|
||||
const info = modelStatus()
|
||||
return footerStatuslinePolicy({
|
||||
width: width(),
|
||||
mainWidth: statuslineMainWidth(),
|
||||
commandWidth: commandHint() ? commandHintWidth() : undefined,
|
||||
agentWidth: agent ? stringWidth(agent) : undefined,
|
||||
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
|
||||
modelWidth: info ? stringWidth(info.model) : undefined,
|
||||
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
|
||||
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
|
||||
mono: props.mono,
|
||||
status: {
|
||||
text: statusText(),
|
||||
expanded: footerDetails() && busy() && interruptLabel() ? `${interruptLabel()} interrupt` : undefined,
|
||||
},
|
||||
escape: shell() && !takeover() ? { key: "esc", label: "normal" } : undefined,
|
||||
work: contextHintCandidates(),
|
||||
model: info ? { name: info.model, variant: info.variant } : undefined,
|
||||
agent: agentStatus(),
|
||||
context:
|
||||
footerDetails() && contextUsage()
|
||||
? {
|
||||
compact: usage()?.percent === undefined ? contextUsage() : `${usage()!.percent}% ctx`,
|
||||
full: contextUsage(),
|
||||
}
|
||||
: undefined,
|
||||
cost: footerDetails() ? cost() : undefined,
|
||||
provider: info?.provider,
|
||||
menu: commandHint(),
|
||||
spinner: busy() ? (props.mono ? "*" : "\u25aa") : undefined,
|
||||
})
|
||||
})
|
||||
const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
|
||||
const hasStatuslineInfo = createMemo(() => {
|
||||
const layout = statuslineLayout()
|
||||
return layout.showUsage || layout.showAgent || layout.showModel
|
||||
const statusSections = createMemo(() => statuslineLayout().groups.filter((group) => group.id !== "spinner"))
|
||||
const statusColors = createMemo(() => ({
|
||||
text: theme().text,
|
||||
muted: theme().muted,
|
||||
agent: agentColor(),
|
||||
status: statusColor(),
|
||||
}))
|
||||
const statusRows = createMemo(() => {
|
||||
const text = statusSections()
|
||||
.map((group) => group.parts.map((part) => part.text).join(""))
|
||||
.join(props.mono ? " - " : " \u00b7 ")
|
||||
if (stringWidth(text) <= statusWidth() && !text.includes("\n")) return 1
|
||||
// Measure outside the clipped footer so wrapped required controls can grow it.
|
||||
const buffer = TextBuffer.create(renderer.widthMethod)
|
||||
const view = TextBufferView.create(buffer)
|
||||
buffer.setText(text)
|
||||
view.setWrapMode("word")
|
||||
view.setWrapWidth(statusWidth())
|
||||
const rows = Math.max(1, view.getVirtualLineCount())
|
||||
view.destroy()
|
||||
buffer.destroy()
|
||||
return rows
|
||||
})
|
||||
createEffect(() => {
|
||||
props.onRows(promptRows() + (!panel() && !menu() && !inspecting() ? statusRows() : 0) - 1)
|
||||
})
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
|
||||
|
||||
createEffect(() => {
|
||||
props.onRequestExit?.(composer.requestExit)
|
||||
@@ -692,6 +694,23 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<Show when={startup()}>
|
||||
{(layout) => (
|
||||
<box id="mini-startup" height={2} flexShrink={0} paddingTop={1} flexDirection="row">
|
||||
<Show when={layout().label.startsWith("\u25aa")}>
|
||||
<OneCellSpinner
|
||||
animation={SEED_LAUNCH}
|
||||
color={runTheme().splash.right}
|
||||
animations={props.tuiConfig.animations}
|
||||
/>
|
||||
</Show>
|
||||
<text fg={runTheme().splash.right} wrapMode="none">
|
||||
{layout().label.startsWith("\u25aa") ? layout().label.slice(1) : layout().label}
|
||||
<span style={{ fg: runTheme().splash.left }}>{layout().metadata}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={panel() || inspecting()}>
|
||||
<box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
</Show>
|
||||
@@ -706,7 +725,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={panel() || prompt() ? false : ["left"]}
|
||||
borderColor={panel() || prompt() ? undefined : theme().highlight}
|
||||
borderColor={panel() || prompt() ? undefined : theme().border}
|
||||
customBorderChars={
|
||||
panel() || prompt()
|
||||
? undefined
|
||||
@@ -733,10 +752,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme}
|
||||
cursorStyle={props.tuiConfig.cursor}
|
||||
background={() => runTheme().background}
|
||||
rail={() => (shell() ? theme().formfieldFocusedText : agentColor())}
|
||||
mono={props.mono}
|
||||
placeholder={composer.placeholder}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onPaste={composer.onPaste}
|
||||
images={composer.images}
|
||||
layout={composer.layout}
|
||||
onContentChange={composer.onContentChange}
|
||||
onSizeChange={composer.onSizeChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
@@ -874,6 +899,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onClose={closePanel}
|
||||
onChange={props.onMiniSettingChange}
|
||||
mono={props.mono}
|
||||
animations={props.tuiConfig.animations}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
@@ -928,128 +954,48 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
rows={composer.rows}
|
||||
limit={FOOTER_MENU_ROWS}
|
||||
border={false}
|
||||
paddingLeft={0}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!panel() && !menu()}>
|
||||
<box
|
||||
id="mini-statusline"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
gap={0}
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<Show when={visibleModeLabel()}>
|
||||
{(label) => (
|
||||
<box
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().statusAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<Show when={busy()}>
|
||||
<box id="mini-work-spinner" width={1} flexShrink={0}>
|
||||
<OneCellSpinner
|
||||
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.miniSettings().work_spinner]}
|
||||
color={agentColor()}
|
||||
animations={props.tuiConfig.animations}
|
||||
glow={!props.mono}
|
||||
still={props.mono ? "*" : undefined}
|
||||
age={performance.now() - (started() ?? performance.now())}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
|
||||
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
|
||||
backgroundColor="transparent"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
footerDetails() &&
|
||||
busy() &&
|
||||
!exiting() &&
|
||||
statuslineMainAvailable() >=
|
||||
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
|
||||
}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
|
||||
<Show when={interruptLabel()}>
|
||||
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
|
||||
</Show>
|
||||
{statusText()}
|
||||
</Show>
|
||||
<Show when={statusSections().length > 0}>
|
||||
<text fg={statusColor()} wrapMode="word" width={statusWidth()} flexShrink={0} height={statusRows()}>
|
||||
<For each={statusSections()}>
|
||||
{(section, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
|
||||
</Show>
|
||||
<For each={section.parts}>
|
||||
{(part) => <span style={{ fg: statusColors()[part.tone] }}>{part.text}</span>}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={statuslineLayout().showUsage && activityMeta()}>
|
||||
{(usage) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().muted} wrapMode="none">
|
||||
{usage()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showAgent && agentStatus()}>
|
||||
{(agent) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
|
||||
{agent()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showModel && modelStatus()}>
|
||||
{(info) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
{info().model}
|
||||
<Show when={statuslineLayout().showVariant && info().variant}>
|
||||
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
|
||||
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint.label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={commandHint()}>
|
||||
{(hint) => (
|
||||
<box backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint().label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1061,7 +1007,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
borderColor={theme().border}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: props.mono ? "|" : "┃",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user