Compare commits

..

6 Commits

Author SHA1 Message Date
Kit Langton 79fa67df51 test(server): update instance fixture replacement syntax 2026-08-31 14:11:20 -04:00
Kit Langton cc12d7b07d chore(core): reconcile snapshot tests with current v2 wiring 2026-08-31 14:08:21 -04:00
Kit Langton f8bd499de5 test(core): make snapshot fixtures portable on Windows 2026-08-31 14:02:02 -04:00
Kit Langton 0002d1df81 refactor(core): simplify snapshot restore and fixtures 2026-08-31 13:35:13 -04:00
Kit Langton 24a226ead0 chore: run snapshot checks outside release branches 2026-08-31 13:17:21 -04:00
Kit Langton f07fb81a6e fix(core): preserve snapshots across session moves 2026-08-31 13:13:24 -04:00
118 changed files with 3022 additions and 8401 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+43 -58
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
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 ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ 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>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
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,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
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,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// 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, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +951,12 @@ 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,
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== 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 message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1098,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 = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1422,9 +1408,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, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1432,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
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 adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
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.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
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, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -82,32 +82,6 @@ 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,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
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", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ 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"])
}),
)
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" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -1,16 +1,5 @@
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-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-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-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-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,7 +1,3 @@
[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);
}
+3 -1
View File
@@ -114,8 +114,10 @@ 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 bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
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",
}}
-90
View File
@@ -1,90 +0,0 @@
[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;
}
}
-47
View File
@@ -1,47 +0,0 @@
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
@@ -1,34 +0,0 @@
[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;
}
+23 -21
View File
@@ -1,8 +1,7 @@
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 { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<MobileDrawer
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<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")}
</MobileDrawerClose>
{/* 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-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
<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")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
@@ -1,3 +1,109 @@
[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;
@@ -7,3 +113,33 @@
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,7 +1,6 @@
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")
+82 -2
View File
@@ -14,13 +14,63 @@
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;
margin-block-start: 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);
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -29,6 +79,36 @@
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;
+22 -12
View File
@@ -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 { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import Drawer from "@corvu/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={
<MobileDrawer
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,9 +423,11 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<MobileDrawerTrigger
<Drawer.Trigger
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")}
>
@@ -465,11 +467,15 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</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">
</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>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -487,6 +493,7 @@ 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={() => {
@@ -497,7 +504,10 @@ 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">
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
@@ -536,9 +546,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
-15
View File
@@ -1,7 +1,6 @@
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(
@@ -57,20 +56,6 @@ 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",
@@ -1,38 +0,0 @@
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),
)
-1
View File
@@ -17,7 +17,6 @@ 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: {
-2
View File
@@ -4,7 +4,6 @@ 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"
@@ -138,7 +137,6 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
export function parseReleaseVersion(input: string) {
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+8 -12
View File
@@ -5,21 +5,19 @@ 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, parseReleaseVersion, type Policy } from "./updater-action"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@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") {}
@@ -112,9 +110,7 @@ export const layer = Layer.effect(
return data.version
})
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 upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -142,7 +138,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}`))
})
@@ -177,7 +173,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check, method, latest, upgrade })
return Service.of({ check })
}),
)
+2 -16
View File
@@ -486,14 +486,7 @@ 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",
work_spinner: "block-low-comet",
mono: true,
}
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -501,14 +494,7 @@ 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",
work_spinner: "block-low-comet",
mono: true,
},
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
-36
View File
@@ -1,36 +0,0 @@
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),
),
)
-2
View File
@@ -9,7 +9,6 @@ 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" }
@@ -146,7 +145,6 @@ 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")
-227
View File
@@ -1,227 +0,0 @@
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"],
])
}),
)
}
-109
View File
@@ -1,109 +0,0 @@
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 })
}
}
+5 -18
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,22 +60,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
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"](...)`.
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"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -163,7 +150,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`. Search also matches descriptions from enclosing `Namespace` values.
`maxToolCalls`.
## Execution Limits
-1
View File
@@ -1,5 +1,4 @@
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"
-24
View File
@@ -1,24 +0,0 @@
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,
})
+1 -1
View File
@@ -53,6 +53,7 @@ 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(),
@@ -98,7 +99,6 @@ 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(
+3 -1
View File
@@ -461,7 +461,9 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
+25 -34
View File
@@ -8,7 +8,6 @@ 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 {
@@ -278,7 +277,6 @@ 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>>
}
@@ -294,10 +292,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
else insert(current, value)
}
}
insert(root, tools)
@@ -307,33 +302,29 @@ 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> = [],
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)),
]
}
): 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(),
]
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)}>`,
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(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))
flattenTools(toolTrie(tools))
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -429,13 +420,12 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
searchText: [
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -443,13 +433,14 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
+1 -1
View File
@@ -127,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
+7 -4
View File
@@ -1,6 +1,4 @@
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
@@ -52,8 +50,13 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
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"
// 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"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}
+4 -35
View File
@@ -25,12 +25,8 @@ 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: 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 toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -282,30 +278,6 @@ 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({
@@ -343,10 +315,7 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
@@ -979,7 +948,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 = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+16 -22
View File
@@ -344,34 +344,28 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf keeps siblings and parenthesized union members in order", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
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")
}
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
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" } },
}),
).toBe("string")
})
})
+1 -43
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Namespace, Tool } from "../src/index.js"
import { CodeMode, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,48 +177,6 @@ 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"]) {
+114 -23
View File
@@ -14,6 +14,8 @@ export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
gitDirectory: AbsolutePath,
commonDirectory: AbsolutePath,
/** Additional read-only object databases; index and worktree remain local. */
objectDirectories: Schema.optional(Schema.Array(AbsolutePath)),
}) {}
// Included from $GIT_DIR/config via include.path (git >= 1.7.10); OpenCode owns
@@ -50,6 +52,7 @@ export class OperationError extends Schema.TaggedError<OperationError>()("Git.Op
"list_files",
"diff",
"restore",
"retain",
]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
@@ -132,6 +135,12 @@ export interface Interface {
}) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
}
readonly tree: {
readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect<boolean>
/** Retain a tree's borrowed objects in this repository, independent of its alternates. */
readonly retain: (input: {
repository: Repository
trees: readonly TreeID[]
}) => Effect.Effect<void, OperationError>
readonly capture: (input: {
repository: Repository
scopes: readonly RelativePath[]
@@ -314,7 +323,16 @@ const layer = Layer.effect(
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
env: {
...(repository.objectDirectories?.length
? {
GIT_ALTERNATE_OBJECT_DIRECTORIES: repository.objectDirectories
.map((directory) => JSON.stringify(directory))
.join(path.delimiter),
}
: {}),
...options?.env,
},
extendEnv: true,
}),
{ stdin: options?.stdin },
@@ -477,6 +495,46 @@ const layer = Layer.effect(
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
})
const treeExists = Effect.fn("Git.tree.exists")((repository: Repository, tree: TreeID) =>
repositoryOperation("list_files", repository, ["cat-file", "-e", `${tree}^{tree}`]).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
),
)
const retain = Effect.fn("Git.tree.retain")((input: { repository: Repository; trees: readonly TreeID[] }) =>
locked(
input.repository,
Effect.gen(function* () {
const retained = (yield* repositoryOperation("retain", input.repository, [
"for-each-ref",
"--format=%(objectname)",
"refs/opencode/snapshots/",
])).text
.trim()
.split("\n")
.filter(Boolean)
const missing = Array.from(new Set(input.trees)).filter((tree) => !retained.includes(tree))
if (!missing.length) return
// Only these refs certify locally retained objects. Ordinary refs or alternates do not.
yield* repositoryOperation(
"retain",
input.repository,
[
"pack-objects",
"--revs",
"--non-empty",
path.join(input.repository.gitDirectory, "objects", "pack", "pack"),
],
{ stdin: [...missing, ...retained.map((tree) => `^${tree}`)].join("\n") + "\n" },
)
yield* repositoryOperation("retain", input.repository, ["update-ref", "--stdin"], {
stdin: missing.map((tree) => `update refs/opencode/snapshots/${tree} ${tree}\n`).join(""),
})
}),
),
)
const captureTree = Effect.fn("Git.tree.capture")(
(input: {
repository: Repository
@@ -586,28 +644,59 @@ const layer = Layer.effect(
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
locked(
input.repository,
Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
}),
{ discard: true },
),
Effect.gen(function* () {
const entries = yield* Effect.forEach(input.files, ([file, tree]) =>
hasEntry(input.repository, tree, file).pipe(
Effect.map((present) => ({ file, tree, present, depth: file.split("/").length })),
),
)
// Remove descendants before any restoration can replace their ancestor with a symlink or file.
yield* Effect.forEach(
entries.toSorted(
(a, b) => Number(a.present) - Number(b.present) || (a.present ? a.depth - b.depth : b.depth - a.depth),
),
({ file, tree, present }) =>
Effect.gen(function* () {
if (present) {
yield* repositoryOperation("restore", input.repository, [
"--literal-pathspecs",
"restore",
`--source=${tree}`,
...(input.repository.objectDirectories?.length ? [] : ["--staged"]),
"--worktree",
"--",
file,
])
// Re-index foreign content without alternates so future local captures can read it.
if (input.repository.objectDirectories?.length)
yield* repositoryOperation(
"restore",
new Repository({ ...input.repository, objectDirectories: undefined }),
["--literal-pathspecs", "add", "--force", "--sparse", "--", file],
)
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
yield* repositoryOperation("restore", input.repository, [
"update-index",
"--force-remove",
"--",
file,
])
}),
{ discard: true },
)
}),
),
)
@@ -690,6 +779,8 @@ const layer = Layer.effect(
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
index: { refresh, ignored },
tree: {
exists: treeExists,
retain,
capture: captureTree,
write: writeTree,
files: treeFiles,
+3 -3
View File
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
const [providerID, ...modelID] = input.split("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
+2 -1
View File
@@ -159,7 +159,8 @@ export const layer = Layer.effect(
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return interrupted
if (next.delivery === "steer" || SessionInbox.isControl(next)) yield* coordinator.wake(sessionID, "steer")
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
+3 -6
View File
@@ -47,9 +47,6 @@ export {
*/
export type Promotable = "input" | "steer"
export const isControl = (item: Pick<Item, "type">): item is Pick<Compaction | Move, "type"> =>
item.type === "compaction" || item.type === "move"
const decodeUser = Schema.decodeUnknownSync(UserPayload)
const encodeUser = Schema.encodeSync(UserPayload)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
@@ -508,7 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
Effect.gen(function* () {
const steers = yield* pendingSteers(db, sessionID)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex(isControl)
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -520,10 +517,10 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued || isControl(queued)) return 0
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex(isControl)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
return (
promoted +
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
+1 -1
View File
@@ -594,7 +594,7 @@ const layer = Layer.effectDiscard(
id: event.data.inboxID,
sessionID: event.data.sessionID,
})
if (SessionInbox.isControl(input)) return
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage(
db,
event,
+10 -12
View File
@@ -54,8 +54,8 @@ const layer = Layer.effect(
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (!pending) return DrainResult.Complete()
if (promotable === "steer" && pending.delivery === "queue" && !SessionInbox.isControl(pending))
return DrainResult.Complete()
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
@@ -96,7 +96,7 @@ const layer = Layer.effect(
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: !entering && 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}`))
@@ -128,16 +128,15 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
const ready = yield* restore(
return yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const scope = entering && !continuing ? promotable : "steer"
const promoted = yield* SessionInbox.promote(db, bus, sessionID, scope)
if (promoted === 0) {
// Cancellation during preparation can expose a control instead of input.
const next = yield* SessionInbox.nextPromotable(db, sessionID, scope)
if (next && SessionInbox.isControl(next)) return undefined
}
const promoted = yield* SessionInbox.promote(
db,
bus,
sessionID,
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -146,7 +145,6 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
+114 -17
View File
@@ -42,9 +42,9 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
* best-effort capture fails.
* Capture the current Location-scoped filesystem state as an opaque reference
* to a content-addressed tree and its storage. Returns `undefined` when
* snapshots are disabled, unsupported, or the best-effort capture fails.
*/
readonly capture: () => Effect.Effect<ID | undefined>
@@ -100,7 +100,18 @@ const layer = Layer.effect(
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
return {
source,
worktree,
snapshotRepository,
foreignRepository: (directory: AbsolutePath) =>
new Git.Repository({
worktree,
gitDirectory: directory,
commonDirectory: directory,
objectDirectories: [AbsolutePath.make(path.join(source.commonDirectory, "objects"))],
}),
}
}).pipe(Effect.forkIn(lifetime)),
)
const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join))
@@ -114,18 +125,80 @@ const layer = Layer.effect(
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const resolved = new Map<ID, { directory: AbsolutePath; tree: Git.TreeID }>()
const resolve = Effect.fnUntraced(function* (id: ID) {
const cached = resolved.get(id)
if (cached) return cached
const qualified = /^snapshot:([a-zA-Z0-9_-]+)\/([a-f0-9]{40})\/([a-f0-9]{40}|[a-f0-9]{64})$/.exec(id)
if (qualified)
return {
directory: AbsolutePath.make(path.join(global.data, "snapshot", qualified[1], qualified[2])),
tree: Git.TreeID.make(qualified[3]),
}
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id))
return yield* new Error({ operation: "restore", message: "Invalid snapshot reference" })
const repo = yield* repository
const tree = Git.TreeID.make(id)
if (yield* git.tree.exists(repo.snapshotRepository, tree))
return { directory: repo.snapshotRepository.gitDirectory, tree }
// Persisted hash-only IDs predate storage-qualified references. Search only on a local miss.
const root = path.join(global.data, "snapshot")
const projects = yield* fs.readDirectoryEntries(root).pipe(Effect.mapError((cause) => failure("restore", cause)))
for (const project of projects.filter(
(entry) => entry.type === "directory" && /^[a-zA-Z0-9_-]+$/.test(entry.name),
)) {
const stores = yield* fs
.readDirectoryEntries(path.join(root, project.name))
.pipe(Effect.mapError((cause) => failure("restore", cause)))
for (const store of stores.filter((entry) => entry.type === "directory" && /^[a-f0-9]{40}$/.test(entry.name))) {
const directory = AbsolutePath.make(path.join(root, project.name, store.name))
const candidate = repo.foreignRepository(directory)
if (!(yield* git.tree.exists(candidate, tree))) continue
// Identical legacy trees can exist in several stores; skip incomplete copies.
if (
!(yield* git.tree.retain({ repository: candidate, trees: [tree] }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
continue
const result = { directory, tree }
resolved.set(id, result)
return result
}
}
return yield* new Error({ operation: "restore", message: `Snapshot tree not found: ${id}` })
})
const read = Effect.fnUntraced(function* (ids: readonly ID[]) {
const repo = yield* repository
const stores = new Map<AbsolutePath, Git.TreeID[]>()
for (const id of new Set(ids)) {
const ref = yield* resolve(id)
if (ref.directory === repo.snapshotRepository.gitDirectory) continue
stores.set(ref.directory, [...(stores.get(ref.directory) ?? []), ref.tree])
}
for (const [directory, trees] of stores) {
// A renamed checkout can supply the old store's borrowed objects at its new path.
yield* git.tree.retain({ repository: repo.foreignRepository(directory), trees })
}
return new Git.Repository({
...repo.snapshotRepository,
objectDirectories: Array.from(stores.keys(), (directory) => AbsolutePath.make(path.join(directory, "objects"))),
})
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!enabled()) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
yield* git.tree.capture({
repository: repo.snapshotRepository,
scopes: [yield* scope(repo.worktree)],
ignores: repo.source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
const tree = yield* git.tree.capture({
repository: repo.snapshotRepository,
scopes: [yield* scope(repo.worktree)],
ignores: repo.source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
})
return ID.make(`snapshot:${location.project.id}/${Hash.fast(repo.worktree)}/${tree}`)
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
)
@@ -133,10 +206,11 @@ const layer = Layer.effect(
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const snapshots = yield* read([input.from, input.to]).pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
repository: snapshots,
from: treeID(input.from),
to: treeID(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
@@ -171,7 +245,23 @@ const layer = Layer.effect(
const absolute = path.resolve(worktree, file)
if (!FSUtil.contains(worktree, absolute))
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
files.set(file, Git.TreeID.make(snapshot))
// Check ancestors, not the leaf: removing a symlink itself must not follow its target.
for (let parent = path.dirname(absolute); parent !== worktree; parent = path.dirname(parent)) {
const canonical = yield* fs.realPath(parent).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
Effect.catchReason("PlatformError", "BadResource", (reason, error) =>
Schema.is(Schema.Struct({ code: Schema.Literal("ENOTDIR") }))(reason.cause)
? Effect.undefined
: Effect.fail(error),
),
Effect.mapError((cause) => failure("restore", cause)),
)
if (canonical === undefined) continue
if (!FSUtil.contains(worktree, canonical))
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
break
}
files.set(file, treeID(snapshot))
}
return files
})
@@ -179,8 +269,11 @@ const layer = Layer.effect(
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
const snapshots = yield* read(Array.from(input.files.values())).pipe(
Effect.mapError((cause) => failure("restore", cause)),
)
yield* git.tree
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
.restore({ repository: snapshots, files: yield* plan(repo.worktree, input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
@@ -206,6 +299,10 @@ export const noopLayer = Layer.succeed(
}),
)
function treeID(id: ID) {
return Git.TreeID.make(id.slice(id.lastIndexOf("/") + 1))
}
function failure(operation: Error["operation"], cause: unknown) {
if (cause instanceof Error && cause.operation === operation) return cause
return new Error({
@@ -147,20 +147,6 @@ 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* () {
@@ -213,30 +199,6 @@ 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",
+40 -1
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Git.node))
@@ -105,6 +105,45 @@ describe("Git worktrees", () => {
})
describe("Git trees", () => {
it.live("retains borrowed tree objects once for restoration without the source repository", () =>
Effect.gen(function* () {
const root = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(root.path, "source"))
const destination = AbsolutePath.make(path.join(root.path, "destination"))
yield* Effect.promise(async () => {
await fs.mkdir(source)
await fs.mkdir(destination)
await initRepo(source)
await Bun.write(path.join(source, "file.txt"), "Borrowed committed content.\n")
await $`git add .`.cwd(source).quiet()
await $`git commit -qm initial`.cwd(source).quiet()
})
const git = yield* Git.Service
const seed = yield* git.repo.discover(source)
if (!seed) throw new Error("Repository not found")
const repository = yield* git.repo.create({
worktree: source,
gitDirectory: AbsolutePath.make(path.join(root.path, "snapshot storage")),
seed,
})
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
expect(yield* git.tree.exists(repository, before)).toBe(true)
expect(yield* git.tree.exists(repository, Git.TreeID.make("0".repeat(40)))).toBe(false)
yield* git.tree.retain({ repository, trees: [before, before] })
yield* Effect.promise(() => Bun.write(path.join(source, "file.txt"), "Snapshot-only content.\n"))
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
yield* git.tree.retain({ repository, trees: [before, after] })
const packs = yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))
yield* git.tree.retain({ repository, trees: [before, after] })
expect(yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))).toEqual(packs)
yield* Effect.promise(() => fs.rm(source, { recursive: true }))
const moved = new Git.Repository({ ...repository, worktree: destination, objectDirectories: [] })
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), before]]) })
expect(yield* read(path.join(destination, "file.txt"))).toBe("Borrowed committed content.\n")
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), after]]) })
expect(yield* read(path.join(destination, "file.txt"))).toBe("Snapshot-only content.\n")
}),
)
;[0, 1, 128].forEach((exitCode) => {
it.live(`refresh handles check-ignore exit ${exitCode}`, () =>
Effect.gen(function* () {
-17
View File
@@ -5,23 +5,6 @@ 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({
-10
View File
@@ -248,16 +248,6 @@ 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(
+246 -60
View File
@@ -7,6 +7,7 @@ import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
@@ -24,99 +25,284 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { initRepo, read } from "./fixture/git"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
Session.node,
SessionExecution.node,
LocationServiceMap.node,
]),
[
Bus.node.replace(Bus.configured({ persist: true })),
Global.node.replace(tempGlobalLayer),
SessionExecution.node.replace(SessionExecution.noopLayer),
// These tests move directories explicitly; native watchers can hold them open on Windows.
Watcher.node.replace(Watcher.configured({ enabled: false })),
],
),
)
describe("Session.revert files", () => {
for (const encoding of ["qualified", "legacy"] as const) {
const stored = (id: Snapshot.ID) =>
encoding === "legacy" ? Snapshot.ID.make(id.slice(id.lastIndexOf("/") + 1)) : id
for (const move of [
"repository rename",
"another worktree",
"another project",
"same worktree subdirectory",
] as const) {
it.live(
`restores ${encoding} staged file changes after moving to ${move}`,
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const destination = AbsolutePath.make(
move === "same worktree subdirectory"
? path.join(directory, "nested")
: path.join(tmp.path, "destination"),
)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(path.join(directory, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated source content.\n")
await initRepo(directory)
await $`git add .`.cwd(directory).quiet()
await $`git commit -qm initial`.cwd(directory).quiet()
if (move === "another worktree")
await $`git worktree add --detach ${destination} HEAD`.cwd(directory).quiet()
if (move === "another project") {
await fs.mkdir(destination)
await Bun.write(path.join(destination, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(destination).quiet()
}
if (move === "same worktree subdirectory") await fs.mkdir(destination)
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(
directory,
() => Bun.write(path.join(directory, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
if (encoding === "legacy")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
expect(reverted.snapshot).toBeDefined()
expect(reverted.files?.map((file) => file.file)).toEqual(["file.txt"])
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
if (move === "repository rename") yield* Effect.promise(() => fs.rename(directory, destination))
const root = move === "same worktree subdirectory" ? directory : destination
yield* Effect.promise(() => Bun.write(path.join(root, "unrelated.txt"), "Keep this destination edit.\n"))
const indexPath = yield* Effect.promise(async () =>
path.resolve(root, (await $`git rev-parse --git-path index`.cwd(root).quiet().text()).trim()),
)
const index = yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())
yield* session.move({ sessionID: created.id, directory: destination })
yield* execution.awaitIdle(created.id)
const moved = yield* session.get(created.id)
expect(moved.location.directory).toBe(destination)
expect(moved.projectID === created.projectID).toBe(move !== "another project")
expect(moved.revert).toEqual(reverted)
yield* session.revert.clear(created.id)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
expect(yield* read(path.join(root, "unrelated.txt"))).toBe("Keep this destination edit.\n")
if (move === "another worktree" || move === "another project")
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
yield* execution.awaitIdle(created.id)
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
expect(yield* read(path.join(root, "file.txt"))).toBe("Before assistant edit.\n")
yield* session.revert.clear(created.id)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect(yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())).toEqual(index)
}),
{ timeout: 15_000 },
)
}
it.live(
`rejects ${encoding} redo through a destination symlink ancestor`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
const file = path.join(source.path, "assets/logo.svg")
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file))
await Bun.write(file, "Before assistant edit.\n")
await initRepo(source.path)
await fs.symlink(path.dirname(file), path.join(destination.path, "assets"), "dir")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(source.path, () => fs.rm(file), stored)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
expect((yield* session.get(created.id)).projectID).not.toBe(created.projectID)
const error = yield* session.revert.clear(created.id).pipe(Effect.flip)
expect(error).toBeInstanceOf(Snapshot.Error)
expect(error).toMatchObject({
operation: "restore",
message: expect.stringContaining("Path escapes the project"),
})
expect(yield* read(file)).toBe("Before assistant edit.\n")
expect((yield* session.get(created.id)).revert).toEqual(reverted)
}),
{ timeout: 15_000 },
)
it.live(
`recovers ${encoding} redo after missing snapshot data returns`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
yield* Effect.promise(async () => {
await initRepo(source.path)
await Bun.write(path.join(source.path, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(source.path, "unrelated.txt"), "Borrowed committed content.\n")
await $`git add .`.cwd(source.path).quiet()
await $`git commit -qm initial`.cwd(source.path).quiet()
await Bun.write(path.join(destination.path, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(
source.path,
() => Bun.write(path.join(source.path, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
const unavailable = {
...reverted,
snapshot: stored(Snapshot.ID.make(`snapshot:missing/${"0".repeat(40)}/${"0".repeat(40)}`)),
}
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: unavailable })
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(unavailable)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
const seed = path.join(source.path, ".git")
const missing = path.join(source.path, "unavailable-seed")
yield* Effect.promise(() => fs.rename(seed, missing))
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(reverted)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* Effect.promise(() => fs.rename(missing, seed))
yield* session.revert.clear(created.id)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
{ timeout: 15_000 },
)
}
it.live(
"undoes and restores a file rename without losing either path",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const directory = (yield* tmpdirScoped()).path
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(original, "Preserve this content.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
const { session, created, prompt } = yield* recordSnapshotStep(directory, () => fs.rename(original, renamed))
const services = LocationServiceMap.Service.get(created.location)
const revert = yield* SessionRevert.Service.pipe(Effect.provide(services))
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
if (!after) throw new Error("Renamed snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* snapshot.files({ from: before, to: after }),
})
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* read(original)).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
expect((yield* session.get(created.id)).revert).toBeUndefined()
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* read(renamed)).toBe("Preserve this content.\n")
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 15_000 },
)
})
const recordSnapshotStep = Effect.fnUntraced(function* (
directory: string,
mutate: () => Promise<unknown>,
stored = (id: Snapshot.ID) => id,
) {
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Edit the files", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: stored(before),
})
yield* Effect.promise(mutate)
const after = yield* snapshot.capture()
if (!after) throw new Error("Edited snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: stored(after),
files: yield* snapshot.files({ from: before, to: after }),
})
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
return { session, created, prompt }
})
+25 -103
View File
@@ -1422,67 +1422,6 @@ describe("SessionRunnerLLM", () => {
})
})
scenario("dispatches a queued move exposed by cancellation during preparation", function* (s) {
const runner = yield* SessionRunner.Service
const prompt = yield* s.session.prompt({ sessionID, text: "Cancel me", delivery: "queue", resume: false })
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: { location, projectID: Project.ID.global },
delivery: "queue",
},
})
s.systemLoadHook = Effect.gen(function* () {
s.systemLoadHook = Effect.void
yield* s.session.cancelInbox({ sessionID, inboxID: prompt.id }).pipe(Effect.orDie)
})
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
expect((yield* s.session.get(sessionID)).location).toEqual(location)
expect(s.closedTransports).toEqual([sessionID])
expect(s.requests).toHaveLength(0)
expect(yield* s.messages).toMatchObject([{ type: "location-switched", location }])
expect(yield* s.inbox).toEqual([])
expect((yield* recordedEventTypes(sessionID)).slice(-2)).toEqual([
Bus.versionedType(SessionEvent.InboxDelivered.type, 1),
Bus.versionedType(SessionEvent.Moved.type, 1),
])
})
scenario("dispatches a queued compaction exposed by cancellation during preparation", function* (s) {
const runner = yield* SessionRunner.Service
const prompt = yield* s.session.prompt({ sessionID, text: "Cancel me", delivery: "queue", resume: false })
const compaction = yield* s.sessionInbox.admitCompaction({
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
s.systemLoadHook = Effect.gen(function* () {
s.systemLoadHook = Effect.void
yield* s.session.cancelInbox({ sessionID, inboxID: prompt.id }).pipe(Effect.orDie)
})
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Complete())
expect(s.requests).toHaveLength(0)
expect(yield* s.inbox).toEqual([])
expect(yield* s.messages).toMatchObject([
{
id: compaction.id,
type: "compaction",
status: "failed",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
},
])
expect((yield* recordedEventTypes(sessionID)).slice(-3)).toEqual([
Bus.versionedType(SessionEvent.InboxDelivered.type, 1),
Bus.versionedType(SessionEvent.Compaction.Started.type, 1),
Bus.versionedType(SessionEvent.Compaction.Failed.type, 1),
])
})
scenario("delivers a queued move atomically at the idle boundary", function* (s) {
const inboxID = SessionMessage.ID.create()
yield* s.sessionInbox.admit({
@@ -1515,49 +1454,32 @@ describe("SessionRunnerLLM", () => {
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
})
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([])
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",
},
})
}
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")
+118 -2
View File
@@ -10,10 +10,76 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
describe("Snapshot", () => {
for (const transition of ["symlink", "file"] as const) {
testEffect(Layer.empty).live(`restores directory/${transition} transitions without touching external files`, () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const project = path.join(tmp.path, "project")
const assets = path.join(project, "assets")
const external = path.join(tmp.path, "external")
const leaf = transition === "symlink" ? "assets/logo.svg" : "assets/nested/logo.svg"
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.mkdir(external)
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\n")
if (transition === "symlink") await fs.symlink(external, assets, "dir")
if (transition === "file") {
await fs.mkdir(path.dirname(path.join(project, leaf)), { recursive: true })
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
yield* Effect.promise(async () => {
await fs.rm(assets, { recursive: true })
if (transition === "file") await Bun.write(assets, "File content.\n")
if (transition === "symlink") {
await fs.mkdir(assets)
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
})
const after = yield* snapshot.capture()
if (!after) throw new Error("Changed snapshot missing")
const files = yield* snapshot.files({ from: before, to: after })
expect(files).toEqual([RelativePath.make("assets"), RelativePath.make(leaf)])
const restored = yield* snapshot
.restore({ files: new Map(files.map((file) => [file, before])) })
.pipe(Effect.exit)
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).exists())).toBe(true)
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
yield* restored
if (transition === "symlink") {
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
yield* Effect.promise(async () => {
await fs.unlink(assets)
await fs.mkdir(assets)
})
yield* snapshot.restore({
files: new Map([
[RelativePath.make("assets"), before],
[RelativePath.make(leaf), after],
]),
})
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
return
}
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
yield* snapshot.restore({ files: new Map(files.toReversed().map((file) => [file, after])) })
expect(yield* read(assets)).toBe("File content.\n")
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
)
}
testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -119,8 +185,46 @@ describe("Snapshot", () => {
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
yield* snapshot.restore({ files: plan })
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
yield* Effect.promise(() => fs.symlink("loop", path.join(location, "loop")))
expect(
yield* snapshot
.restore({ files: new Map([[RelativePath.make("scope/loop/file.txt"), before]]) })
.pipe(Effect.flip),
).toMatchObject({
operation: "restore",
cause: { reason: { cause: { code: "ELOOP" } } },
})
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
const error = yield* snapshot
.restore({
files: new Map([[RelativePath.make("../escape.txt"), before]]),
})
.pipe(Effect.flip)
expect(error.message).toContain("Path escapes the project")
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "escape.txt")).exists())).toBe(false)
expect(
yield* snapshot
.restore({
files: new Map([
[
RelativePath.make("scope/tracked.txt"),
Snapshot.ID.make(`snapshot:../${"0".repeat(40)}/${"0".repeat(40)}`),
],
]),
})
.pipe(Effect.flip),
).toMatchObject({ operation: "restore", message: "Invalid snapshot reference" })
yield* Effect.promise(async () => {
await Bun.write(path.join(tmp.path, "target.txt"), "Keep the external target.\n")
await fs.symlink(path.join(tmp.path, "target.txt"), path.join(location, "link.txt"))
})
yield* snapshot.restore({ files: new Map([[RelativePath.make("scope/link.txt"), before]]) })
expect(yield* Effect.promise(() => Bun.file(path.join(location, "link.txt")).exists())).toBe(false)
expect(yield* read(path.join(tmp.path, "target.txt"))).toBe("Keep the external target.\n")
yield* Effect.promise(() => fs.rm(location, { recursive: true }))
yield* snapshot.restore({ files: plan })
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
}).pipe(Effect.provide(layer))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -214,8 +318,20 @@ describe("Snapshot", () => {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
yield* Effect.promise(() => Bun.write(path.join(project, "tracked.txt"), "Uncommitted source content.\n"))
const captured = yield* capture(project)
if (!captured) throw new Error("Snapshot missing")
expect(yield* capture(linked)).toBeDefined()
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
expect(
yield* snapshot.diff({
from: captured,
to: Snapshot.ID.make(captured.slice(captured.lastIndexOf("/") + 1)),
}),
).toEqual([])
expect(yield* read(path.join(linked, "tracked.txt"))).toBe("main\n")
}).pipe(Effect.provide(snapshotLayer(tmp.path, linked)))
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
-16
View File
@@ -241,22 +241,6 @@ 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* () {
@@ -1,78 +0,0 @@
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>
)
}
+45 -10
View File
@@ -51,7 +51,13 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
@@ -1449,19 +1455,36 @@ 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 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"}]`)
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)
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) => {
@@ -1486,6 +1509,18 @@ 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,38 +26,6 @@ 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",
-21
View File
@@ -26,24 +26,6 @@ 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({
@@ -203,9 +185,6 @@ 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",
}),
+1 -1
View File
@@ -268,7 +268,7 @@ export const Definitions = {
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
+1 -1
View File
@@ -230,7 +230,7 @@ export const Definitions = {
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
@@ -9,11 +9,10 @@ import { useDialog } from "../../ui/dialog"
const id = "opencode.plugins"
type Entry =
| { readonly key: string; readonly runtime: "server"; readonly internal: boolean; readonly plugin: PluginInfo }
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
| {
readonly key: string
readonly runtime: "tui"
readonly internal: boolean
readonly id?: string
readonly target: string
readonly status: "active" | "inactive" | "failed"
@@ -29,7 +28,7 @@ export function PluginsDialog(props: {
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
@@ -42,7 +41,6 @@ export function PluginsDialog(props: {
.map((plugin) => ({
key: `tui:${plugin.id}`,
runtime: "tui" as const,
internal: true,
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
@@ -53,7 +51,6 @@ export function PluginsDialog(props: {
.map((plugin) => ({
key: `tui:${plugin.id ?? plugin.target}`,
runtime: "tui" as const,
internal: false,
id: plugin.id,
target: plugin.target,
status: plugin.status,
@@ -62,7 +59,6 @@ export function PluginsDialog(props: {
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
runtime: "server" as const,
internal: plugin.source.type === "builtin",
plugin,
}))
return [
@@ -70,15 +66,16 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
createEffect(() => {
if (visibleEntries().some((entry) => entry.key === focused())) return
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
setFocused(first?.key)
if (initial()) return
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const options = createMemo(() =>
visibleEntries().map(
entries().map(
(entry): DialogSelectOption<string> => ({
title: label(entry, props.context),
value: entry.key,
@@ -136,28 +133,12 @@ export function PluginsDialog(props: {
<DialogSelect
title="Plugins"
options={options()}
current={initial()}
locked={locked()}
preserveSelection={true}
bindings={[
{
bind: "ctrl+a",
title: "Toggle internal plugins",
group: "Plugins",
run: () => {
setShowInternal((value) => !value)
},
},
]}
footerHints={[{ title: "ctrl+a", label: `${showInternal() ? "hide" : "show"} internal` }]}
onMove={(option) => setFocused(option.value)}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (
entry?.runtime === "tui" &&
entry.id &&
props.plugins.registered().some((plugin) => plugin.id === entry.id)
)
return toggle(entry)
if (pluginError(entry)) setDetail(entry)
}}
actions={
@@ -5,7 +5,6 @@ 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
@@ -17,7 +16,7 @@ export type Story = {
render: (context: Plugin.Context) => JSX.Element
}
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, oneCellSpinnerStory]
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -1,144 +0,0 @@
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,
]
@@ -1,293 +0,0 @@
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} />,
}
@@ -1,115 +0,0 @@
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,
})),
)
-19
View File
@@ -32,7 +32,6 @@ import type {
} from "./types"
const KINDS = [
"motion",
"markdown",
"table",
"text",
@@ -153,7 +152,6 @@ type State = {
perms: Map<string, Perm>
forms: Map<string, FormRequest>
started: Set<string>
motion?: AbortController
}
type Input = {
@@ -816,17 +814,6 @@ 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
@@ -913,7 +900,6 @@ function intro(state: State): void {
"- /form question",
"- /form external",
"- /fmt markdown",
"- /fmt motion",
"- /fmt table",
"- /fmt text your custom text",
].join("\n"),
@@ -1051,11 +1037,6 @@ export function createRunDemo(input: Input) {
return {
start,
prompt,
interrupt() {
if (!state.motion) return false
state.motion.abort()
return true
},
permission,
formReply,
formCancel,
-5
View File
@@ -194,11 +194,6 @@ 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)
}
+51 -159
View File
@@ -1,20 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
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 { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import type {
@@ -88,12 +78,7 @@ 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
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 PANEL_PAGE = PANEL_LIST_ROWS - 1
const HALF_BLOCK_BORDER = {
topLeft: "",
bottomLeft: "",
@@ -153,17 +138,10 @@ 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: () => layout().limit })
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
const selected = () => items()[menu.selected()]
createEffect(() => {
@@ -183,7 +161,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
})
createEffect(() => {
input.onRows?.(menu.rows() + layout().frame)
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
})
useKeyboard((event) => {
@@ -223,13 +201,13 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
if (name === "pageup") {
event.preventDefault()
menu.reveal(menu.selected() - Math.max(1, menu.limit() - 1))
menu.reveal(menu.selected() - PANEL_PAGE)
return
}
if (name === "pagedown") {
event.preventDefault()
menu.reveal(menu.selected() + Math.max(1, menu.limit() - 1))
menu.reveal(menu.selected() + PANEL_PAGE)
return
}
@@ -266,7 +244,6 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
setQuery,
items,
menu,
layout,
inputRef(input: InputRenderable) {
field = input
},
@@ -286,66 +263,50 @@ function PanelShell(props: {
children: JSX.Element
hint?: string
mono?: boolean
background?: boolean
layout: ReturnType<typeof footerPanelLayout>
}) {
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 background = () => props.theme().shade
const content = (
<>
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={pad()}
paddingRight={pad()}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
flexDirection="row"
gap={0}
gap={1}
flexShrink={0}
backgroundColor={background()}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{header().title}
{props.title}
</text>
{header().count ? (
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{" " + header().count}
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<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
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
</text>
</box>
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={pad()}
paddingRight={pad()}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
flexShrink={0}
backgroundColor={background()}
>
<input
width="100%"
focusedBackgroundColor={props.background === false ? "transparent" : props.theme().formfieldFocusedBg}
focusedTextColor={
props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText
}
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
@@ -358,7 +319,7 @@ function PanelShell(props: {
}}
/>
</box>
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box height={1} flexShrink={0} backgroundColor={background()} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
@@ -369,14 +330,8 @@ function PanelShell(props: {
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
<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} border={false} backgroundColor="transparent" flexShrink={0}>
{props.mono ? null : (
<box
width="100%"
height={1}
@@ -607,7 +562,6 @@ export function RunCommandMenuBody(props: {
return (
<PanelShell
title="Commands"
layout={controller.layout()}
countVisible={false}
query={controller.query()}
count={controller.items().length}
@@ -623,9 +577,8 @@ export function RunCommandMenuBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -656,7 +609,6 @@ 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,
@@ -673,7 +625,6 @@ export function RunAgentSelectBody(props: {
return (
<PanelShell
title="Select agent"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -688,9 +639,8 @@ export function RunAgentSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No agents found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -709,7 +659,6 @@ 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[]>(() => [
@@ -717,7 +666,6 @@ 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",
},
@@ -725,7 +673,6 @@ 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",
},
@@ -733,7 +680,6 @@ 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",
},
@@ -741,7 +687,6 @@ 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",
},
@@ -749,7 +694,6 @@ 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",
},
@@ -757,46 +701,16 @@ 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, direction = 1) => {
const change = (item: SettingEntry) => {
if (saving()) return
const spinners = Config.MiniWorkSpinner.literals
const next: MiniSettingChange =
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" }
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(() => {})
@@ -811,7 +725,7 @@ export function RunSettingsBody(props: {
const name = event.name.toLowerCase()
if (name !== "left" && name !== "right") return false
event.preventDefault()
if (item) change(item, name === "left" ? -1 : 1)
if (item) change(item)
return true
},
})
@@ -819,7 +733,6 @@ export function RunSettingsBody(props: {
return (
<PanelShell
title="Settings"
layout={controller.layout()}
countVisible={false}
query={controller.query()}
count={controller.items().length}
@@ -836,9 +749,8 @@ export function RunSettingsBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No settings found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -873,12 +785,6 @@ 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,
@@ -904,7 +810,6 @@ export function RunSubagentSelectBody(props: {
return (
<PanelShell
title="Select subagent"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -921,8 +826,7 @@ export function RunSubagentSelectBody(props: {
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={controller.menu.limit()}
compact={controller.layout().compact}
limit={SUBAGENT_LIST_ROWS}
empty="No subagents found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -981,7 +885,6 @@ export function RunQueuedPromptSelectBody(props: {
return (
<PanelShell
title="Queued prompts"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -998,8 +901,7 @@ export function RunQueuedPromptSelectBody(props: {
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={controller.menu.limit()}
compact={controller.layout().compact}
limit={SUBAGENT_LIST_ROWS}
empty="No queued prompts"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -1041,7 +943,6 @@ export function RunSkillSelectBody(props: {
return (
<PanelShell
title="Skills"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -1056,9 +957,8 @@ export function RunSkillSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={panelPad(props.mono)}
@@ -1083,8 +983,7 @@ export function RunVariantSelectBody(props: {
{
category: "",
display: "Default",
footer: props.current() === undefined ? "current" : undefined,
footerTone: "selection",
description: props.current() === undefined ? "current" : undefined,
keywords: "default",
variant: undefined,
current: props.current() === undefined,
@@ -1092,8 +991,7 @@ export function RunVariantSelectBody(props: {
...props.variants().map((variant) => ({
category: "",
display: variant,
footer: props.current() === variant ? "current" : undefined,
footerTone: "selection" as const,
description: props.current() === variant ? "current" : undefined,
keywords: variant,
variant,
current: props.current() === variant,
@@ -1110,7 +1008,6 @@ export function RunVariantSelectBody(props: {
return (
<PanelShell
title="Select variant"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -1125,9 +1022,8 @@ export function RunVariantSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -1170,7 +1066,6 @@ 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,
}
@@ -1201,7 +1096,6 @@ export function RunModelSelectBody(props: {
return (
<PanelShell
title="Select model"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -1210,26 +1104,24 @@ export function RunModelSelectBody(props: {
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
background={false}
>
<RunFooterMenu
theme={props.theme}
items={() =>
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.query().trim()
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
: controller.items()
}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
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}
/>
+170 -383
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { BoxRenderable, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import {
createFormBodyState,
@@ -34,7 +34,6 @@ 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
@@ -46,11 +45,6 @@ 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
@@ -72,39 +66,11 @@ 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))
@@ -177,12 +143,6 @@ 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
@@ -248,11 +208,6 @@ 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 (
@@ -301,346 +256,178 @@ export function RunFormBody(props: {
})
return (
<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 ? "*" : "◆"}
<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"}
</text>
<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}`}
<Show when={state().error}>
<text fg={props.theme.error} wrapMode="none" truncate>
{state().error}
</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>
)
}
+59 -67
View File
@@ -1,30 +1,20 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { useTerminalDimensions } 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 =
@@ -32,10 +22,10 @@ type RunFooterMenuRow =
| { type: "item"; item: RunFooterMenuItem; index: number }
| { type: "spacer" }
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number | Accessor<number> }) {
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
const [selected, setSelected] = createSignal(0)
const [offset, setOffset] = createSignal(0)
const limit = () => Math.max(1, typeof input.limit === "function" ? input.limit() : (input.limit ?? FOOTER_MENU_ROWS))
const limit = () => input.limit ?? FOOTER_MENU_ROWS
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
const reveal = (index: number) => {
@@ -68,7 +58,6 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
selected,
offset,
rows,
limit,
reveal,
reset,
move,
@@ -87,17 +76,13 @@ export function RunFooterMenu(props: {
paddingLeft?: number
paddingRight?: number
grouped?: boolean
compact?: boolean
background?: boolean
headerColor?: ColorInput
mono?: boolean
}) {
const term = useTerminalDimensions()
const limit = () => Math.max(1, Math.min(props.rows(), props.limit ?? FOOTER_MENU_ROWS))
const limit = () => 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[]>(() => {
@@ -105,12 +90,12 @@ export function RunFooterMenu(props: {
let category = ""
props.items().forEach((item, index) => {
if (item.category && item.category !== category) {
if (all.length > 0 && !props.compact) {
if (all.length > 0) {
all.push({ type: "spacer" })
}
category = item.category
if (!props.compact) all.push({ type: "header", label: item.category })
all.push({ type: "header", label: item.category })
}
all.push({ type: "item", item, index })
@@ -166,11 +151,34 @@ 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"
>
@@ -188,8 +196,8 @@ export function RunFooterMenu(props: {
<box
flexGrow={1}
flexShrink={1}
paddingLeft={paddingLeft()}
paddingRight={paddingRight()}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={props.background ? props.theme().shade : transparent}
>
<text fg={props.theme().muted} wrapMode="none" truncate>
@@ -205,9 +213,9 @@ export function RunFooterMenu(props: {
if (row.type === "header") {
return (
<box height={1} flexShrink={0} paddingLeft={paddingLeft()} paddingRight={paddingRight()}>
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
<text
fg={props.headerColor ?? props.theme().muted}
fg={props.headerColor ?? props.theme().highlight}
attributes={TextAttributes.BOLD}
wrapMode="none"
truncate
@@ -219,87 +227,71 @@ 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.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)
}
active()
? props.background
? props.theme().selected
: props.theme().shade
: props.background
? props.theme().shade
: transparent
return (
<box height={1} flexShrink={0} paddingRight={0} flexDirection="row" backgroundColor={background()}>
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
{border() ? (
<text fg={props.theme().actionFocusedText} bg={background()} wrapMode="none">
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
{active() ? (props.mono ? ">" : "▌") : " "}
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={paddingLeft()}
paddingRight={paddingRight()}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
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().actionFocusedText : props.theme().formfieldText}
fg={active() ? props.theme().selectedText : props.theme().text}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
>
{footerMenuText(
row.item.display,
available() - (footer() ? stringWidth(footer()!) + 1 : 0),
props.mono,
)}
{row.item.display}
</text>
{description() ? (
{row.item.description ? (
<>
<text
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
flexShrink={0}
>
{" ".repeat(Math.max(1, descriptionColumn() - stringWidth(row.item.display)))}
{descriptionPad(row.item)}
</text>
<text
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{description()}
{descriptionText(row.item)}
</text>
</>
) : undefined}
</box>
{footer() ? (
{row.item.footer ? (
<text
fg={active() ? props.theme().actionFocusedText : props.theme()[row.item.footerTone ?? "muted"]}
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
>
{footer()}
{row.item.footer}
</text>
) : undefined}
</box>
+136 -196
View File
@@ -11,7 +11,7 @@
// The diff view (when available) uses the same diff component as scrollback
// tool snapshots.
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
import { TextAttributes, 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 { stringWidth } from "../util/string-width"
import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
@@ -44,16 +44,13 @@ function buttons(
mono: boolean,
) {
return (
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
<box flexDirection="row" gap={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.actionFocusedBg : transparent}
backgroundColor={option === selected ? theme.highlight : transparent}
onMouseOver={() => {
if (!disabled) onHover(option)
}}
@@ -62,8 +59,7 @@ function buttons(
}}
>
<text
wrapMode="none"
fg={option === selected ? theme.actionFocusedText : theme.actionSecondaryText}
fg={option === selected ? theme.surface : theme.muted}
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
>
{permissionLabel(option)}
@@ -112,11 +108,11 @@ export function RejectField(props: {
wrapMode="word"
placeholder="Tell OpenCode what to do differently"
placeholderColor={props.theme.muted}
textColor={props.theme.formfieldText}
focusedTextColor={props.theme.formfieldFocusedText}
textColor={props.theme.text}
focusedTextColor={props.theme.text}
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.formfieldFocusedBg}
cursorColor={props.theme.formfieldFocusedText}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
focused={!props.disabled}
onSubmit={props.onConfirm}
onContentChange={() => {
@@ -148,14 +144,10 @@ 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))
let scroll: ScrollBoxRenderable | undefined
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const scrollbar = createMemo(() => ({
visible: !props.mono,
trackOptions: {
@@ -164,25 +156,19 @@ export function RunPermissionBody(props: {
},
}))
const opts = createMemo(() =>
permissionOptions(stage()).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
permissionOptions(state().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 (stage() === "always") {
if (state().stage === "always") {
return "Always allow"
}
if (stage() === "reject") {
return width() < 24 ? "Reject" : "Reject permission"
if (state().stage === "reject") {
return "Reject permission"
}
return width() < 24 ? "Permission" : "Permission required"
return "Permission required"
})
createEffect(() => {
@@ -198,12 +184,6 @@ 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,
@@ -253,12 +233,6 @@ 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()
@@ -299,50 +273,58 @@ export function RunPermissionBody(props: {
})
return (
<box
width="100%"
height="100%"
minHeight={0}
flexDirection="column"
backgroundColor={props.theme.surface}
onSizeChange={function () {
setSize({ width: this.width, height: this.height })
}}
>
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
<box
paddingLeft={compact() ? 0 : 2}
paddingRight={compact() ? 0 : 3}
paddingTop={compact() ? 0 : 1}
paddingBottom={compact() ? 0 : 1}
gap={compact() ? 0 : 1}
flexDirection="column"
gap={1}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
flexShrink={0}
>
<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 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>
</box>
<Show
when={stage() !== "reject"}
when={state().stage !== "reject"}
fallback={
<box width="100%" flexGrow={1} minHeight={0} justifyContent="flex-end">
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
<box
backgroundColor={props.theme.line}
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}
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}
>
<box width={width() >= 80 ? undefined : "100%"} flexGrow={1} flexShrink={1} minWidth={0}>
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
<RejectField
theme={props.theme}
text={state().message}
@@ -360,17 +342,17 @@ export function RunPermissionBody(props: {
<Show
when={!busy()}
fallback={
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
{compact() ? "Waiting..." : "Waiting for permission event..."}
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
</text>
}
>
<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>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
</text>
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
esc <span style={{ fg: props.theme.muted }}>{compact() ? "back" : "cancel"}</span>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>cancel</span>
</text>
</box>
</Show>
@@ -378,46 +360,21 @@ export function RunPermissionBody(props: {
</box>
}
>
<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>
<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}>
<Show
when={info().diff}
fallback={
<Show
when={info().patch}
fallback={
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={info().lines}>
{(line) => (
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
<text fg={props.theme.text} wrapMode="word">
{line}
</text>
)}
@@ -429,16 +386,13 @@ export function RunPermissionBody(props: {
<Show
when={props.block.syntax}
fallback={
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
<text fg={props.theme.muted} wrapMode="word">
{patch()}
</text>
}
>
{(syntax) => (
<code
width="100%"
flexShrink={0}
wrapMode="word"
filetype="diff"
drawUnstyledText={false}
streaming={true}
@@ -452,107 +406,93 @@ export function RunPermissionBody(props: {
</Show>
}
>
<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>
<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>
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
<text width="100%" fg={props.theme.muted} flexShrink={0}>
No diff provided
</text>
<box paddingLeft={1}>
<text fg={props.theme.muted}>No diff provided</text>
</box>
</Show>
</box>
</Match>
<Match when={true}>
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
</scrollbox>
</Match>
<Match when={true}>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line) => (
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
<text fg={props.theme.text} wrapMode="word">
{line}
</text>
)}
</For>
</box>
</Match>
</Switch>
</scrollbox>
</scrollbox>
</Match>
</Switch>
</box>
<box
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}
flexDirection={narrow() ? "column" : "row"}
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"}
>
<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>
{buttons(
opts(),
state().selected,
props.theme,
busy(),
(option) => {
setState((prev) => permissionHover(prev, option))
},
run,
props.mono ?? false,
)}
<Show
when={!busy()}
fallback={
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
{compact() ? "Waiting..." : "Waiting for permission event..."}
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
</text>
}
>
<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>
<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>
</Show>
</box>
</Show>
+46 -294
View File
@@ -5,38 +5,15 @@
// 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,
decodePasteBytes,
fg,
stripAnsiSequences,
type ColorInput,
type KeyEvent,
type PasteEvent,
type TextareaRenderable,
} from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } 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 {
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
onMount,
type Accessor,
} from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import {
createPromptHistory,
displayCharAt,
@@ -53,7 +30,8 @@ import {
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
import { Keymap } from "../context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_COMPACT_WIDTH, FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
@@ -66,31 +44,13 @@ 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
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 }
}
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
@@ -126,11 +86,8 @@ 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>
@@ -155,20 +112,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: [] }
}
@@ -220,17 +177,11 @@ 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()
@@ -276,53 +227,12 @@ export function RunPromptBody(props: {
})
return (
<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>
<box width="100%">
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
<textarea
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={props.layout().textarea}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.placeholder()}
placeholderColor={props.theme().muted}
@@ -334,8 +244,8 @@ export function RunPromptBody(props: {
cursorStyle={props.cursorStyle}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={(event) => {
void props.onPaste(event).finally(refreshPasteLayout)
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
@@ -348,10 +258,6 @@ 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()) {
@@ -362,9 +268,7 @@ export function createPromptState(input: PromptInput): PromptState {
return ""
}
return new StyledText([
fg(input.theme().muted)(`Ask anything, / for commands, @ for context${input.mono() ? "..." : "…"}`),
])
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
})
let history = createPromptHistory(input.history?.())
@@ -379,31 +283,6 @@ 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)
@@ -411,12 +290,11 @@ 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(0, input.width() - (input.width() < FOOTER_COMPACT_WIDTH ? 2 : 4)))
const width = createMemo(() => Math.max(20, input.width() - 8))
const agents = createMemo<Auto[]>(() => {
return input
.agents()
@@ -439,7 +317,9 @@ export function createPromptState(input: PromptInput): PromptState {
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: "@" + item.name,
display: input.mono()
? monoTruncateMiddle("@" + item.name, width(), true)
: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
@@ -459,7 +339,7 @@ export function createPromptState(input: PromptInput): PromptState {
},
}))
})
const [fileResults] = createResource(
const [files] = createResource(
query,
async (value) => {
if (!visible() || mode() !== "mention") {
@@ -481,7 +361,9 @@ export function createPromptState(input: PromptInput): PromptState {
return {
kind: "mention",
display: "@" + filename,
display: input.mono()
? monoTruncateMiddle("@" + filename, width(), true)
: Locale.truncateMiddle("@" + filename, width()),
value: filename,
directory: item.endsWith("/"),
part: {
@@ -504,15 +386,6 @@ 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[]>(() =>
@@ -609,17 +482,10 @@ export function createPromptState(input: PromptInput): PromptState {
})
.map((item) => item.obj)
})
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: AUTOCOMPLETE_ROWS })
const popup = createMemo(() => {
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
})
const menu = createFooterMenuState({ count: () => options().length, limit: () => Math.max(1, layout().menu) })
const hide = () => {
setMode(false)
@@ -632,8 +498,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
setLines(Math.max(area.lineCount, area.virtualLineCount))
input.onRows(layout().rows)
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
}
const scheduleRows = () => {
@@ -653,10 +518,8 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const next = parts.map<Mention | undefined>((part) =>
part.type === "file" && !part.source?.text ? part : undefined,
)
let tracked = 0
const next: Mention[] = []
const map = new Map<number, number>()
for (const item of area.extmarks.getAllForTypeId(type)) {
const idx = marks.get(item.id)
if (idx === undefined) {
@@ -693,15 +556,15 @@ export function createPromptState(input: PromptInput): PromptState {
copy.source.text.value = text
}
tracked += 1
next[idx] = copy
map.set(item.id, next.length)
next.push(copy)
}
const retained = next.filter((part): part is Mention => part !== undefined)
const stale = tracked !== marks.size || retained.length !== parts.length
parts = retained
const stale = map.size !== marks.size
parts = next
marks = map
if (stale) {
restoreParts(retained)
restoreParts(next)
}
}
@@ -711,7 +574,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
parts = []
marks = new Map()
setDraftParts([])
}
const restoreParts = (value: RunPromptPart[]) => {
@@ -719,7 +581,6 @@ 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
}
@@ -743,7 +604,6 @@ 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) {
@@ -759,7 +619,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
const resetDraft = () => {
revision += 1
if (area && !area.isDestroyed) {
area.setText("")
}
@@ -860,7 +719,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
syncParts()
setDraftParts(parts)
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
draft = shell()
? {
@@ -875,87 +733,6 @@ 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)
}
@@ -1011,8 +788,7 @@ export function createPromptState(input: PromptInput): PromptState {
const requestExit = () => {
const text = area && !area.isDestroyed ? area.plainText : draft.text
revision += 1
if (input.prompt() && (text.length > 0 || draft.parts.some((part) => part.type === "file"))) {
if (input.prompt() && text.length > 0) {
input.onInputClear()
resetDraft()
return true
@@ -1257,12 +1033,6 @@ 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",
@@ -1285,7 +1055,8 @@ export function createPromptState(input: PromptInput): PromptState {
group: "Prompt",
palette: true,
run() {
onSubmit("queue")
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
],
@@ -1446,7 +1217,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (submitting) return
if (!next.text.trim() && !next.parts.some((part) => part.type === "file")) {
if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
@@ -1518,17 +1289,9 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const onSubmit = (delivery: RunDelivery = "steer") => {
if (pasteQueue) {
const before = revision
void pasteQueue.then(() => {
if (revision === before) onSubmit(delivery)
})
return
}
if (disposed || !input.prompt()) return
const onSubmit = () => {
syncDraft()
submitPrompt(promptCopy(draft), delivery)
submitPrompt(promptCopy(draft))
}
const submitText = (text: string) => {
@@ -1536,20 +1299,14 @@ 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()
layout()
popup()
if (input.prompt()) {
scheduleRows()
}
@@ -1604,22 +1361,17 @@ 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,
}
+53 -124
View File
@@ -1,14 +1,12 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard } from "@opentui/solid"
import { registerOpencodeSpinner } from "../component/register-spinner"
import { Show, createMemo, createSignal, indexArray } from "solid-js"
import { Show, createMemo, 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()
@@ -16,7 +14,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 14
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
if (status === "completed") {
return theme.success
return theme.highlight
}
if (status === "cancelled") {
@@ -27,7 +25,7 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
return theme.error
}
return theme.running
return theme.highlight
}
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
@@ -61,10 +59,6 @@ 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())
@@ -108,11 +102,6 @@ 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()) {
@@ -140,130 +129,70 @@ 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%"
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)}
<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>
</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" flexShrink={0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{hint()} interrupt
</text>
)}
</Show>
<Show when={count()}>
{(value) => (
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
{value()}
</text>
)}
<Show when={props.total() > 1 && props.index() > 0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{props.index()} of {props.total()}
</text>
</Show>
</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>
</box>
)}
</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"}
</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
</text>
)}
</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>
</scrollbox>
</box>
</box>
)
}
+48 -136
View File
@@ -24,22 +24,19 @@
// 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, type CliRendererExternalOutputEvent } from "@opentui/core"
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
import { batch, createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { 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_SUBAGENT_PANEL_ROWS, footerPanelLayout } from "./footer.command"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { TEXTAREA_MIN_ROWS, footerPromptLayout } from "./footer.prompt"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { monoSnapshot } from "./mono"
import { RunScrollbackStream } from "./scrollback.surface"
import { resolveRunTheme, type RunTheme } from "./theme"
import { RUN_THEME_FALLBACK, 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,
@@ -78,7 +75,6 @@ type RunFooterOptions = {
agents: RunAgent[]
references: RunReference[]
wrote?: boolean
startup?: { version: string; detail: string }
agent: string | undefined
modelLabel: string
model: RunInput["model"]
@@ -86,12 +82,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>
@@ -212,9 +208,6 @@ 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
@@ -232,8 +225,7 @@ export class RunFooter implements FooterApi {
.finally(() => this.destroyTheme(theme))
},
shellOutput: () => this.miniSettings().shell_output === "show",
mono: this.miniSettings().mono,
imagePreview: this.options.tuiConfig.session?.image_preview,
mono: this.options.mono,
})
}
@@ -246,7 +238,7 @@ export class RunFooter implements FooterApi {
status: "",
notice: "",
model: options.modelLabel,
usage: undefined,
usage: "",
first: options.first,
interrupt: 0,
exit: 0,
@@ -308,19 +300,16 @@ 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)
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)
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)
}
const footer = this
void render(
@@ -331,7 +320,6 @@ 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,
@@ -342,14 +330,13 @@ 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,
get mono() {
return footer.miniSettings().mono
},
mono: options.mono,
miniSettings: footer.miniSettings,
history: footer.history,
onSubmit: footer.handlePrompt,
@@ -360,10 +347,7 @@ export class RunFooter implements FooterApi {
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: (input) => {
footer.finishStartup()
return options.onEditorOpen(input)
},
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,
@@ -386,26 +370,6 @@ 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 {
@@ -451,7 +415,6 @@ 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
@@ -553,7 +516,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: "usage" in next ? next.usage : prev.usage,
usage: typeof next.usage === "string" ? next.usage : prev.usage,
first: typeof next.first === "boolean" ? next.first : prev.first,
interrupt:
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
@@ -588,7 +551,6 @@ export class RunFooter implements FooterApi {
return
}
if (view.type !== "prompt") this.finishStartup()
this.setView(view)
this.applyHeight()
}
@@ -602,7 +564,6 @@ 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
@@ -653,7 +614,6 @@ export class RunFooter implements FooterApi {
return
}
this.finishStartup()
this.scrollback.destroy()
this.scrollback = this.createScrollback(wrote)
}
@@ -681,7 +641,6 @@ export class RunFooter implements FooterApi {
return
}
this.finishStartup()
this.flush()
this.notifyClose()
}
@@ -734,34 +693,23 @@ export class RunFooter implements FooterApi {
this.patch({ interrupt: 0, exit: 0 })
}
private handleResize = (): void => {
if (!this.isGone) this.applyHeight()
}
// 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 applyHeight(): void {
const type = this.view().type
const route = this.promptRoute.type
const panel = footerPanelLayout(this.renderer.terminalHeight)
const prompt = footerPromptLayout(this.renderer.terminalHeight)
const desired =
const height =
type === "permission"
? this.base + PERMISSION_ROWS
: type === "form"
? this.base + FORM_ROWS
: ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
? 1 + panel.frame + panel.limit
? 1 + RUN_COMMAND_PANEL_ROWS
: route === "queued-menu" || route === "subagent-menu"
? 1 + this.subagentMenuRows
: route === "subagent"
? this.base + SUBAGENT_INSPECTOR_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),
),
)
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -773,7 +721,7 @@ export class RunFooter implements FooterApi {
return
}
const rows = Math.max(TEXTAREA_MIN_ROWS, value)
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
if (rows === this.rows) {
return
}
@@ -785,7 +733,6 @@ 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") {
@@ -914,29 +861,8 @@ export class RunFooter implements FooterApi {
}
try {
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")
this.setMiniSettings(await this.options.miniSettings.update(change))
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
} catch (error) {
this.setNotice("failed to save settings")
throw error
@@ -1033,34 +959,26 @@ export class RunFooter implements FooterApi {
return true
}
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)
}
private handlePalette = (): void => {
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
return
}
private handleExternalOutput = (event: CliRendererExternalOutputEvent): void => {
if (this.miniSettings().mono) monoSnapshot(event)
}
// Keep the last known good theme when a runtime OSC probe times out.
if (theme === RUN_THEME_FALLBACK) {
return
}
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.themes.push(theme)
this.setTheme(theme)
this.renderer.setBackgroundColor(theme.background)
this.flushing = this.flushing
.then(() => {
if (this.isGone || mono !== this.miniSettings().mono) {
theme.block.syntax?.destroy()
return
}
this.applyTheme(theme)
})
.then(() => this.scrollback.setTheme(theme))
.catch((error) => {
this.flushError = error
})
return this.flushing
})
}
@@ -1075,11 +993,10 @@ export class RunFooter implements FooterApi {
return false
}
private handleThemeRefresh = (): Promise<void> | undefined => {
if (this.isGone) {
private handleThemeRefresh = (): void => {
if (this.isGone || this.options.mono) {
return
}
if (this.miniSettings().mono) return this.handlePalette()
if (this.paletteRefreshRunning) {
this.paletteRefreshQueued = true
@@ -1089,23 +1006,22 @@ export class RunFooter implements FooterApi {
this.paletteRefreshRunning = true
const retry = this.renderer.paletteDetectionStatus === "detecting"
this.renderer.clearPaletteCache()
return this.renderer
void this.renderer
.getPalette({ size: 256 })
.catch(() => {})
.then(() => {
.finally(() => {
this.paletteRefreshRunning = false
if (!retry && !this.paletteRefreshQueued) {
// Theme files can change without a new terminal palette.
return this.handlePalette()
return
}
this.paletteRefreshQueued = false
return this.handleThemeRefresh()
this.handleThemeRefresh()
})
}
public refreshTheme() {
return this.handleThemeRefresh()
public refreshTheme(): void {
this.handleThemeRefresh()
}
private handleThemeSignal = (): void => {
@@ -1123,8 +1039,6 @@ export class RunFooter implements FooterApi {
return
}
clearTimeout(this.startupTimer)
this.startupTimer = undefined
this.flush()
this.destroyed = true
this.notifyClose()
@@ -1132,8 +1046,6 @@ 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)
+231 -177
View File
@@ -8,12 +8,10 @@
// All state comes from the parent RunFooter through SolidJS signals.
// The view itself is stateless except for derived memos.
/** @jsxImportSource @opentui/solid */
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextBuffer, TextBufferView } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { OneCellSpinner } from "../component/one-cell-spinner"
import { WORK_SPINNERS, SEED_LAUNCH, SEED_MONO } from "../ui/one-cell-motion"
import { entrySplashLayout } from "./splash"
import { registerOpencodeSpinner } from "../component/register-spinner"
import { createColors, createFrames } from "../ui/spinner"
import {
RUN_SUBAGENT_PANEL_ROWS,
RunAgentSelectBody,
@@ -33,11 +31,9 @@ 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"
@@ -63,7 +59,7 @@ import type {
} from "./types"
import type { RunTheme } from "./theme"
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
registerOpencodeSpinner()
const EMPTY_BORDER = {
topLeft: "",
@@ -88,11 +84,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[]
@@ -101,7 +97,6 @@ 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>
@@ -127,13 +122,8 @@ 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 (
@@ -191,7 +181,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) : undefined
return current ? modelInfo(props.providers(), current).model : undefined
})
const detail = createMemo(() => {
const current = route()
@@ -208,18 +198,10 @@ 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 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 footerDetails = createMemo(() => props.miniSettings().footer === "show")
const interruptLabel = createMemo(() => {
if (!interrupt()) {
return
@@ -229,22 +211,31 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const runTheme = createMemo(() => props.theme())
const theme = createMemo(() => runTheme().footer)
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 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 footerStatus = createMemo(() => {
const current = model()?.model ?? props.state().model.trim()
const current = 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 (contextUsage()) details.push(contextUsage())
if (cost()) details.push(cost())
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
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 ? " - " : " · ")
@@ -372,7 +363,6 @@ export function RunFooterView(props: RunFooterViewProps) {
openTab(next.sessionID)
}
const [promptRows, setPromptRows] = createSignal(1)
const composer = createPromptState({
directory: props.directory,
findFiles: props.findFiles,
@@ -383,11 +373,8 @@ 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),
@@ -400,44 +387,63 @@ export function RunFooterView(props: RunFooterViewProps) {
onExit: props.onExit,
onSkillMenu: openSkillMenu,
onSettings: openSettings,
onRows: setPromptRows,
onRows: props.onRows,
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 statusText = createMemo(() => {
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]!
const modeLabel = createMemo(() => {
if (exiting()) {
return "EXIT"
}
if (notice()) return notice()
if (!footerDetails()) return shell() ? "Shell" : ""
if (busy()) {
return interruptLabel() ? `${interruptLabel()} stop` : "Running"
return shell() ? "SHELL" : undefined
})
const modeColor = createMemo(() => {
if (exiting()) {
return theme().error
}
return stateStatus() || (shell() ? "Shell" : "")
if (shell()) {
return theme().warning
}
return theme().highlight
})
const statusText = createMemo(() => {
if (exiting()) {
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
}
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()
}
return shell() ? "Shell mode" : ""
})
const activityMeta = createMemo(() => {
if (!footerDetails()) return ""
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
})
const agentStatus = createMemo(() => {
if (!footerDetails() || !prompt() || shell()) return undefined
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
return props.currentAgent()
})
const modelStatus = createMemo(() => {
const current = model()?.model ?? props.state().model.trim()
const current = model() ?? props.state().model.trim()
if (!footerDetails() || !prompt() || shell() || !current) return
return {
model: current,
provider: model()?.provider,
variant: props.currentVariant(),
}
})
@@ -447,7 +453,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
if (armed()) {
return theme().warning
return theme().highlight
}
if (busy() || notice().length > 0 || stateStatus().length > 0) {
@@ -456,87 +462,79 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().muted
})
const statuslineBackground = createMemo(() => theme().status)
const contextHintCandidates = createMemo(() => {
if (!footerDetails() || !prompt() || shell()) {
return []
}
const items: Array<{ id: "queued" | "subagents" | "background"; key: string; label: string; expanded?: string }> =
[]
const items: Array<{ key: string; label: string }> = []
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
}
if (queue().length > 0 && queuedShortcut()) {
items.push({ id: "queued", key: queuedShortcut(), label: `${queue().length} queued` })
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
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" })
items.push({ key: subagentShortcut(), label: "subagents" })
}
return items
})
const commandHint = createMemo(() => {
if (!prompt() || takeover() || shell()) return
if (!prompt()) return
if (shell()) {
return { key: "esc", label: "normal" }
}
if (command()) {
return { key: command(), label: "menu" }
return { key: command(), label: "cmd" }
}
})
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(),
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,
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,
})
})
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 contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
const hasStatuslineInfo = createMemo(() => {
const layout = statuslineLayout()
return layout.showUsage || layout.showAgent || layout.showModel
})
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
createEffect(() => {
props.onRequestExit?.(composer.requestExit)
@@ -694,23 +692,6 @@ 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>
@@ -725,7 +706,7 @@ export function RunFooterView(props: RunFooterViewProps) {
width="100%"
flexShrink={0}
border={panel() || prompt() ? false : ["left"]}
borderColor={panel() || prompt() ? undefined : theme().border}
borderColor={panel() || prompt() ? undefined : theme().highlight}
customBorderChars={
panel() || prompt()
? undefined
@@ -752,16 +733,10 @@ 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>
@@ -899,7 +874,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onClose={closePanel}
onChange={props.onMiniSettingChange}
mono={props.mono}
animations={props.tuiConfig.animations}
/>
</Match>
<Match when={active().type === "permission"}>
@@ -954,48 +928,128 @@ export function RunFooterView(props: RunFooterViewProps) {
rows={composer.rows}
limit={FOOTER_MENU_ROWS}
border={false}
paddingLeft={2}
paddingRight={2}
paddingLeft={0}
mono={props.mono}
/>
</Show>
<Show when={!panel() && !menu()}>
<box
id="mini-statusline"
width="100%"
height={1}
flexDirection="row"
gap={1}
gap={0}
flexShrink={0}
backgroundColor="transparent"
backgroundColor={statuslineBackground()}
>
<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 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>
<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>
<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>
</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>
@@ -1007,7 +1061,7 @@ export function RunFooterView(props: RunFooterViewProps) {
flexGrow={1}
flexShrink={1}
border={["left"]}
borderColor={theme().border}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: props.mono ? "|" : "┃",
+43 -109
View File
@@ -1,120 +1,54 @@
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
type FooterAction = {
key: string
label: string
expanded?: string
export function footerWidthPolicy(width: number) {
return {
dialog: {
narrow: width < 80,
},
}
}
export type FooterStatuslineGroup = {
id:
| "spinner"
| "status"
| "escape"
| "queued"
| "subagents"
| "background"
| "agent"
| "model"
| "context"
| "cost"
| "provider"
| "menu"
parts: Array<{ text: string; tone: "text" | "muted" | "agent" | "status" }>
}
const USAGE_HEADROOM = 8
export function footerStatuslinePolicy(input: {
width: number
mono?: boolean
status?: { text: string; expanded?: string }
escape?: FooterAction
work: Array<FooterAction & { id: "queued" | "subagents" | "background" }>
model?: { name: string; variant?: string }
agent?: string
context?: { compact: string; full: string }
cost?: string
provider?: string
menu?: FooterAction
spinner?: string
mainWidth: number
commandWidth?: number
agentWidth?: number
contextWidths: number[]
modelWidth?: number
variantWidth?: number
usageWidth?: number
}) {
const group = (
id: FooterStatuslineGroup["id"],
text: string,
tone: FooterStatuslineGroup["parts"][number]["tone"] = "muted",
): FooterStatuslineGroup => ({ id, parts: [{ text, tone }] })
const action = (id: FooterStatuslineGroup["id"], value: FooterAction, expanded = false): FooterStatuslineGroup => ({
id,
parts: [
{ text: value.key, tone: "text" },
{ text: ` ${expanded ? (value.expanded ?? value.label) : value.label}`, tone: "muted" },
],
})
const identity = (cells: number) => {
const name = input.model!.name
const ellipsis = input.mono ? "..." : "\u2026"
const text =
stringWidth(name) > cells + stringWidth(ellipsis) ? Locale.takeWidth(name, cells).trimEnd() + ellipsis : name
return group("model", text + (input.model!.variant ? ` [${input.model!.variant}]` : ""), "text")
let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
let hasSection = input.commandWidth !== undefined
const include = (width: number | undefined, headroom = 0) => {
if (width === undefined) return false
const required = width + (hasSection ? 3 : 1)
if (remaining < required + headroom) return false
remaining -= required
hasSection = true
return true
}
const selected = new Map<FooterStatuslineGroup["id"], FooterStatuslineGroup>()
if (input.spinner) selected.set("spinner", group("spinner", input.spinner, "text"))
if (input.status?.text) selected.set("status", group("status", input.status.text, "status"))
if (input.escape) selected.set("escape", action("escape", input.escape))
const place = () => {
const order: FooterStatuslineGroup["id"][] = [
"spinner",
"status",
"escape",
"queued",
"subagents",
"background",
"agent",
"model",
"context",
"cost",
"provider",
"menu",
]
const groups = order.flatMap((id) => selected.get(id) ?? [])
const separator = input.mono ? " - " : " \u00b7 "
return {
groups,
text: groups
.map(
(item, index) =>
(index === 0 ? "" : groups[index - 1]!.id === "spinner" ? " " : separator) +
item.parts.map((part) => part.text).join(""),
)
.join(""),
}
}
let layout = place()
// Required controls may wrap. Optional information never crowds that fallback.
if (stringWidth(layout.text) > input.width || layout.text.includes("\n")) return layout
const showModel = include(input.modelWidth)
const showAgent = include(input.agentWidth)
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
const contextComplete = contextCount === input.contextWidths.length
const variantWidth = input.variantWidth
const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
if (showVariant) remaining -= variantWidth
const showUsage =
(showModel || input.modelWidth === undefined) &&
(showAgent || input.agentWidth === undefined) &&
contextComplete &&
(showVariant || input.variantWidth === undefined) &&
include(input.usageWidth, USAGE_HEADROOM)
// Each stage retains earlier information. Stop at the first non-fitting stage:
// backfilling shorter, lower-priority groups would make resizing unstable.
const stages = [
...input.work.map((item) => action(item.id, item)),
...(input.model ? [identity(8)] : []),
...(input.agent ? [group("agent", input.agent, "agent")] : []),
...(input.context ? [group("context", input.context.compact)] : []),
...(input.model ? [identity(24)] : []),
...(input.context ? [group("context", input.context.full)] : []),
...(input.cost ? [group("cost", input.cost)] : []),
...(input.provider ? [group("provider", input.provider)] : []),
...(input.menu ? [action("menu", input.menu)] : []),
...(input.model ? [identity(Infinity)] : []),
...input.work.filter((item) => item.expanded).map((item) => action(item.id, item, true)),
...(input.status?.expanded ? [group("status", input.status.expanded, "status")] : []),
]
for (const stage of stages) {
selected.set(stage.id, stage)
const next = place()
if (stringWidth(next.text) > input.width || next.text.includes("\n")) break
layout = next
return {
showAgent,
contextCount,
showModel,
showVariant,
showUsage,
}
return layout
}
+2 -4
View File
@@ -58,9 +58,7 @@ export function isCompactCommand(input: string): boolean {
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? [])
.filter((item) => item.text.trim().length > 0 || item.parts.some((part) => part.type === "file"))
.map(promptCopy)
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const next: RunPrompt[] = []
for (const item of list) {
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
@@ -78,7 +76,7 @@ export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
}
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
if (!prompt.text.trim() && !prompt.parts.some((part) => part.type === "file")) {
if (!prompt.text.trim()) {
return state
}
-1
View File
@@ -91,7 +91,6 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
splash: config?.mini?.splash ?? "show",
work_spinner: config?.mini?.work_spinner ?? "block-soft-slide",
mono: config?.mini?.mono ?? false,
}
}
+45 -36
View File
@@ -9,16 +9,10 @@
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import {
CliRenderEvents,
buildKittyKeyboardFlags,
createCliRenderer,
type CliRenderer,
type ScrollbackWriter,
} from "@opentui/core"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash } from "./splash"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
FooterApi,
@@ -87,7 +81,7 @@ export type Lifecycle = {
onResize(fn: () => void): () => void
refreshTheme(): void
setTitle(title?: string): void
resetForReplay(): Promise<void>
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@@ -112,9 +106,19 @@ function shutdown(renderer: CliRenderer): void {
}
}
function splashTitle(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) return title
return history.find((item) => item.text.trim().length > 0)?.text ?? title
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) {
return {
title,
showSession: true,
}
}
const next = history.find((item) => item.text.trim().length > 0)
return {
title: next?.text ?? title,
showSession: !!next,
}
}
function directoryLabel(directory: string, home: string) {
@@ -184,25 +188,27 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
entry: false,
exit: false,
}
const startup =
miniSettings.splash === "show" && !mono && tuiConfig.animations !== false
? { version: input.host.version, detail: directoryLabel(input.getDirectory(), input.host.paths.home) }
: undefined
queueSplash(
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
mono,
})
const wrote = queueSplash(
renderer,
state,
"entry",
miniSettings.splash === "show" && !startup
miniSettings.splash === "show"
? entrySplash({
version: input.host.version,
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
})
: undefined,
)
await renderer.idle().catch(() => {})
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const { RunFooter } = await footerTask
let closed = false
@@ -221,22 +227,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
first: input.first,
history: input.history,
theme,
mono,
// The transcript always starts one row below the terminal's prior output,
// even when the entry splash itself is hidden.
wrote: true,
startup,
wrote: wrote || miniSettings.splash === "hide",
tuiConfig,
miniSettings: {
current: miniSettings,
update: input.onMiniSettingChange,
},
onMonoChange: (mono) => {
if (mono) {
renderer.disableKittyKeyboard()
return
}
renderer.enableKittyKeyboard(buildKittyKeyboardFlags({ events: input.host.platform === "win32" }))
},
onPermissionReply: input.onPermissionReply,
onFormReply: input.onFormReply,
onFormCancel: input.onFormCancel,
@@ -312,20 +311,23 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
let wroteExit = false
try {
footer.finishStartup()
await footer.idle().catch(() => {})
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
wroteExit = queueSplash(
renderer,
state,
"exit",
exitSplash({
title: splashTitle(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history),
session_id: sessionID,
...splashMeta({
title: splash.title,
session_id: sessionID,
mono,
}),
theme: footer.currentTheme().splash,
mono: footer.currentMiniSettings().mono,
mono,
}),
)
await renderer.idle().catch(() => {})
@@ -335,6 +337,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
await footer.idle().catch(() => {})
footer.destroy()
if (input.host.platform === "linux") renderer.setTerminalTitle("")
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
shutdown(renderer)
if (!wroteExit) {
input.host.stdout.write("\n")
@@ -363,7 +366,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
renderer.on(CliRenderEvents.RESIZE, resize)
return () => renderer.off(CliRenderEvents.RESIZE, resize)
},
async resetForReplay() {
async resetForReplay(next) {
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
@@ -375,12 +378,18 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.resetForReplay(true)
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
renderer.writeToScrollback(
entrySplash({
version: input.host.version,
...splashMeta({
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
mono,
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono: footer.currentMiniSettings().mono,
mono,
}),
)
renderer.requestRender()
+2 -5
View File
@@ -172,7 +172,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
break
}
if (sent.mode !== "shell" && sent.text.trim()) {
if (sent.mode !== "shell") {
const commit = {
kind: "user",
text: sent.text,
@@ -252,10 +252,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
}
const submit = (prompt: RunPrompt) => {
if (
state.closed ||
(!prompt.text.trim() && (prompt.mode === "shell" || !prompt.parts.some((part) => part.type === "file")))
) {
if (!prompt.text.trim() || state.closed) {
return
}
+13 -8
View File
@@ -366,7 +366,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
},
onInterrupt: () => {
if (state.demo?.interrupt()) return true
if (!state.sessionID) {
return false
}
@@ -412,10 +411,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const thinking = () => input.thinking ?? configState.current.thinking === "show"
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const offRuntimeClose = footer.onClose(() => {
state.demo?.interrupt()
runtimeController.abort()
})
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
let clientGeneration = 0
let clientController = new AbortController()
let modelAttempt: AbortController | undefined
@@ -484,7 +480,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
if (footer.isClosed || runtimeController.signal.aborted) return
await shell.resetForReplay()
await shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
})
})
.catch((error) => {
if (footer.isClosed || runtimeController.signal.aborted) return
@@ -848,7 +848,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () => shell.resetForReplay(),
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
}),
)
.catch(() => {})
@@ -981,7 +986,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
type: "stream.patch",
patch: {
phase: "idle",
usage: undefined,
usage: "",
first: true,
},
})
@@ -32,6 +32,7 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
if (commit.phase === "final") {
return {
fg: theme.system.body,
attrs: TextAttributes.DIM,
}
}
@@ -48,6 +49,7 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
if (commit.kind === "reasoning") {
return {
fg: theme.reasoning.body,
attrs: TextAttributes.DIM,
}
}
+5 -100
View File
@@ -1,11 +1,11 @@
// Retained streaming append logic for direct-mode scrollback.
//
// Static text entries are rendered through `scrollback.writer.tsx`. Retained
// surfaces keep streaming markdown/code layout stable and wait for image loads
// before their snapshots enter scrollback.
// Static entries are rendered through `scrollback.writer.tsx`. This file only
// keeps the retained-surface machinery needed for streaming assistant,
// reasoning, and tool progress entries that need stable markdown/code layout
// while content is still arriving.
import {
CodeRenderable,
ImageRenderable,
MarkdownRenderable,
TextRenderable,
getTreeSitterClient,
@@ -87,13 +87,10 @@ export class RunScrollbackStream {
private tail: StreamCommit | undefined
private rendered: StreamCommit | undefined
private active: ActiveEntry | undefined
private imageSurface: ScrollbackSurface | undefined
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private shellOutput: () => boolean
private mono: boolean
private imagePreview: boolean
private destroyed = false
private pendingThemes: RunTheme[] = []
constructor(
@@ -105,34 +102,17 @@ export class RunScrollbackStream {
onThemeRelease?: (theme: RunTheme) => void
shellOutput?: () => boolean
mono?: boolean
imagePreview?: boolean
} = {},
) {
this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false
this.shellOutput = options.shellOutput ?? (() => true)
this.mono = options.mono ?? false
this.imagePreview = options.imagePreview ?? false
this.onThemeRelease = options.onThemeRelease
}
private onThemeRelease: ((theme: RunTheme) => void) | undefined
public async setMono(mono: boolean): Promise<void> {
if (this.mono === mono) return
const active = this.active
if (active?.body.type !== "markdown") await this.complete()
this.mono = mono
if (active?.body.type !== "markdown") return
// Rebuild the Markdown tree, keeping its source and printed block boundary.
// Mono hooks are one-way, and ending the entry would lose open fence/list context.
const next = this.createEntry(active.commit, active.body)
this.active = { ...active, surface: next.surface, renderable: next.renderable }
active.surface.destroy()
this.releasePendingThemes()
}
private releasePendingThemes(): void {
if (this.pendingThemes.length === 0) {
return
@@ -368,77 +348,11 @@ export class RunScrollbackStream {
}
}
private async writeImage(commit: StreamCommit): Promise<void> {
const surface = this.renderer.createScrollbackSurface(entryFlags(commit))
this.imageSurface = surface
try {
const image = new ImageRenderable(surface.renderContext, {
source: commit.image,
fit: "fit",
visible: false,
alignSelf: "flex-start",
flexShrink: 0,
})
surface.root.add(image)
// settle() waits for code highlighting, not image decoding.
await image.loadPromise
if (surface.isDestroyed) return
const body = entryBody(commit, { mono: this.mono })
if (body.type !== "text") return
const style = entryLook(commit, this.theme.entry)
const caption = new TextRenderable(surface.renderContext, {
content: body.content + (image.image ? "" : "\nNo preview"),
width: "100%",
wrapMode: "word",
fg: style.fg,
attributes: style.attrs,
flexShrink: 0,
})
surface.root.add(caption, 0)
surface.render()
if (image.image && !this.mono) {
const resolution = this.renderer.resolution
const fitted = image.getFittedSize(
Math.min(
surface.width,
resolution?.width
? Math.max(1, Math.floor((image.image.width * this.renderer.terminalWidth) / resolution.width))
: Infinity,
),
Math.min(
Math.max(0, this.renderer.terminalHeight - this.renderer.height - caption.height),
resolution?.height
? Math.max(1, Math.floor((image.image.height * this.renderer.terminalHeight) / resolution.height))
: Infinity,
),
image.cellAspectRatio,
)
// fit centers within its rectangle, so the rectangle itself must be fitted.
image.width = fitted.width
image.height = fitted.height
image.visible = fitted.width > 0 && fitted.height > 0
surface.render()
}
this.writeSpacer(separatorRows(this.rendered, commit, body) || (!this.rendered && this.wrote ? 1 : 0))
surface.commitRows(0, surface.height, { trailingNewline: entryFlags(commit).trailingNewline })
this.markRendered(commit)
} finally {
surface.destroy()
this.imageSurface = undefined
}
}
public async append(commit: StreamCommit): Promise<void> {
if (this.destroyed || this.renderer.isDestroyed) return
const same = sameEntryGroup(this.tail, commit)
if (!same || commit.image) {
if (!same) {
this.markRendered(await this.finishActive(false))
}
if (this.destroyed || this.renderer.isDestroyed) return
if (commit.summary) {
this.writeSpacer(1)
@@ -448,12 +362,6 @@ export class RunScrollbackStream {
return
}
if (commit.image && this.imagePreview && !this.mono) {
await this.writeImage(commit)
this.tail = commit
return
}
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono })
if (body.type === "none") {
if (entryDone(commit)) {
@@ -518,9 +426,6 @@ export class RunScrollbackStream {
}
public destroy(): void {
this.destroyed = true
this.imageSurface?.destroy()
this.imageSurface = undefined
this.resetActive()
this.releasePendingThemes()
}
+12 -18
View File
@@ -6,7 +6,7 @@ import {
type ScrollbackRenderContext,
type ScrollbackWriter,
} from "@opentui/core"
import { For, Match, Switch, createMemo } from "solid-js"
import { Match, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
@@ -39,7 +39,6 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
}
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.image) return "block"
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"
@@ -254,22 +253,17 @@ export function RunEntryContent(props: {
</box>
</Match>
<Match when={markdown()}>
{/* Mono hooks mutate renderables, so toggling needs a fresh Markdown leaf. */}
<For each={[props.opts?.mono === true]}>
{(mono) => (
<markdown
ref={(renderable: MarkdownRenderable) => {
if (mono) monoMarkdownRenderable(renderable)
}}
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
fg={color()}
tableOptions={mono ? monoMarkdownTableOptions : { widthMode: "content" }}
/>
)}
</For>
<markdown
ref={(renderable: MarkdownRenderable) => {
if (props.opts?.mono) monoMarkdownRenderable(renderable)
}}
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
fg={color()}
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
/>
</Match>
</Switch>
)
+8 -18
View File
@@ -1,5 +1,4 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { projectedPromptInput } from "../prompt/codec"
import { promptCopy, promptSame } from "./prompt.shared"
import type { RunInput, RunPrompt } from "./types"
@@ -21,39 +20,30 @@ export type RunSession = {
variant?: string
}
export function messagePrompt(message: Pick<SessionMessageUser, "text" | "files" | "agents" | "skills">): RunPrompt {
const input = projectedPromptInput(message)
function messagePrompt(message: SessionMessageUser): RunPrompt {
return {
text: input.text,
text: message.text,
parts: [
...(input.files ?? []).map((file, index) => ({
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.uri,
mime: message.files?.[index]?.mime,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
...(file.description === undefined ? {} : { description: file.description }),
source: file.mention
? {
type: "file",
path: file.name ?? (file.uri.startsWith("data:") ? "inline attachment" : file.uri),
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(input.agents ?? []).map((agent) => ({
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
...(input.skills ?? []).map((skill) => ({
type: "skill" as const,
id: skill.id,
source: skill.mention
? { start: skill.mention.start, end: skill.mention.end, value: skill.mention.text }
: undefined,
})),
],
}
}
@@ -102,7 +92,7 @@ function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
return session.turns
.map((turn) => turn.prompt)
.filter((prompt) => prompt.text.trim() || prompt.parts.some((part) => part.type === "file"))
.filter((prompt) => prompt.text.trim())
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
.map(promptCopy)
.slice(-limit)
+65 -88
View File
@@ -1,22 +1,25 @@
// Entry and exit splash banners for direct interactive mode scrollback.
//
// The entry header is a single flex row; the exit banner retains its cell-based
// logo and resume information. Both become immutable terminal history.
// Renders the full opencode entry logo and a compact [O] exit badge, plus
// session metadata and the resume command. These are scrollback snapshots, so
// they become immutable terminal history once committed.
//
// Both variants use a cell-based renderer. cells() classifies each character
// in the source template as text, full-block, half-block-mix, or
// half-block-top, and draw() renders it with foreground/background shadow
// colors from the theme.
import {
BoxRenderable,
type ColorInput,
TextAttributes,
TextRenderable,
StyledText,
fg,
type ScrollbackRenderContext,
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { go } from "../logo"
import { monoTruncate } from "./mono"
import { monoTruncate, monoTruncateMiddle } from "./mono"
import type { RunSplashTheme } from "./theme"
const SPLASH_TITLE_LIMIT = 50
@@ -31,6 +34,7 @@ type SplashInput = {
type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
detail?: string
}
export type SplashMeta = {
@@ -169,23 +173,52 @@ function draw(
}
}
function buildExit(input: SplashWriterInput, ctx: ScrollbackRenderContext): ScrollbackSnapshot {
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
const width = Math.max(1, ctx.width)
const meta = splashMeta(input)
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
const left = input.theme.left
const right = input.theme.right
const leftShadow = input.theme.leftShadow
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
const label = "Continue "
const command = `opencode mini -s ${meta.session_id}`
const wide = body_left + stringWidth(label + command) <= width
const commandHeight = wide ? 1 : Math.ceil(stringWidth(command) / width)
let height = 1
if (kind === "entry") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
top: top + i,
fg: left,
shadow: leftShadow,
})
}
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
if (input.detail) {
push(
lines,
body_left,
top + 1,
input.mono
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
}
height = top + Math.max(mark.length, input.detail ? 2 : 1)
}
if (kind === "exit") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
const label = "Continue "
if (wide) {
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
@@ -196,13 +229,23 @@ function buildExit(input: SplashWriterInput, ctx: ScrollbackRenderContext): Scro
}
if (input.showSession !== false) {
push(lines, body_left, top, session, left)
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
}
push(lines, body_left, top + 1, label, left)
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
push(
lines,
body_left + label.length,
top + 1,
`opencode mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,
)
height = top + Math.max(mark.length, 2)
}
const height = top + (wide ? Math.max(mark.length, 2) : commandHeight)
const root = new BoxRenderable(ctx.renderContext, {
position: "absolute",
left: 0,
@@ -214,19 +257,6 @@ function buildExit(input: SplashWriterInput, ctx: ScrollbackRenderContext): Scro
for (const line of lines) {
write(root, ctx, line)
}
root.add(
new TextRenderable(ctx.renderContext, {
position: "absolute",
left: wide ? body_left + label.length : 0,
top: wide ? top + 1 : top,
width: wide ? width - body_left - label.length : width,
height: commandHeight,
wrapMode: "char",
content: command,
fg: right,
attributes: TextAttributes.BOLD,
}),
)
return {
root,
@@ -245,63 +275,10 @@ export function splashMeta(input: SplashInput): SplashMeta {
}
}
export function entrySplash(input: {
version: string
detail?: string
mono?: boolean
theme: RunSplashTheme
}): ScrollbackWriter {
return (ctx) => {
const width = Math.max(1, ctx.width)
const layout = entrySplashLayout({ ...input, width })
const root = new BoxRenderable(ctx.renderContext, {
width,
height: 2,
paddingTop: 1,
flexDirection: "row",
overflow: "hidden",
})
root.add(
new TextRenderable(ctx.renderContext, {
content: new StyledText([fg(input.theme.right)(layout.label), fg(input.theme.left)(layout.metadata)]),
width,
height: 1,
wrapMode: "none",
}),
)
return { root, width, height: 2, rowColumns: width, startOnNewLine: true, trailingNewline: false }
}
}
export function entrySplashLayout(input: { width: number; version: string; detail?: string; mono?: boolean }) {
const detail = input.detail ?? ""
const segments = detail.split(/[/\\]/).filter(Boolean)
const leaf = segments.at(-1) ?? detail
const separator = input.mono ? " - " : " · "
const ellipsis = input.mono ? "..." : "…"
const slash = detail.includes("\\") ? "\\" : "/"
const paths = segments
.slice(1)
.map((_, index) => ellipsis + slash + segments.slice(index + 1).join(slash))
.reverse()
.filter((path) => stringWidth(path) < stringWidth(detail))
let layout = { label: Locale.takeWidth("oc mini", input.width), version: "", path: "", metadata: "" }
const stages = [
{ label: `${input.mono ? "[O]" : "▪"} oc mini` },
...(leaf ? [{ path: leaf }] : []),
...(input.version ? [{ version: input.version }] : []),
...paths.concat(detail ? [detail] : []).map((path) => ({ path })),
]
// Stop at the first non-fitting stage instead of backfilling lower-priority metadata.
for (const stage of stages) {
const next = { ...layout, ...stage }
const metadata = (next.version ? ` v${next.version}` : "") + (next.path ? separator + next.path : "")
if (stringWidth(next.label + metadata) > input.width) break
layout = { ...next, metadata }
}
return layout
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "entry", ctx)
}
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => buildExit(input, ctx)
return (ctx) => build(input, "exit", ctx)
}
-38
View File
@@ -1,38 +0,0 @@
import type { SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client/promise"
import { deduplicateVisibleImages } from "../prompt/attachment"
import { toolDisplayContent } from "../util/tool-display"
import type { StreamCommit } from "./types"
export type ImageCommit = StreamCommit & { image: string; messageID: string; partID: string }
export function userImageCommits(messageID: string, files: SessionMessageUser["files"]): ImageCommit[] {
return deduplicateVisibleImages(files ?? [])
.filter((file) => file.mime.startsWith("image/"))
.map((file, index) => ({
kind: "user",
source: "system",
text: file.name ?? file.mention?.text ?? `[Image ${index + 1}]`,
image: `data:${file.mime};base64,${file.data}`,
phase: "final",
messageID,
partID: `image:${index}`,
}))
}
export function toolImageCommits(part: SessionMessageAssistantTool, messageID: string): ImageCommit[] {
return toolDisplayContent(part.state)
.flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [content]
: [],
)
.map((content, index) => ({
kind: "tool",
source: "tool",
text: content.name ?? `[Image ${index + 1}]`,
image: content.uri,
phase: "final",
messageID,
partID: `prt_${part.id}:image:${index}`,
}))
}
+20 -32
View File
@@ -21,11 +21,9 @@ import type {
PermissionRequest,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { Locale } from "../util/locale"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { toolImageCommits, userImageCommits } from "./stream-v2.image"
import type {
FooterSubagentDetail,
FooterSubagentState,
@@ -117,7 +115,7 @@ type ChildState = {
permissions: MiniPermissionRequest[]
forms: MiniFormRequest[]
messageIDs: Set<string>
prompts: Map<string, Pick<SessionMessageUser, "text" | "files">>
prompts: Map<string, string>
hydrated: boolean
detailStale: boolean
blockersHydrated: boolean
@@ -289,19 +287,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (meta.background === true) child.background = true
}
const userFrame = (child: ChildState, messageID: string, prompt: Pick<SessionMessageUser, "text" | "files">) => {
const userFrame = (child: ChildState, messageID: string, value: string) => {
if (child.messageIDs.has(messageID)) return false
child.messageIDs.add(messageID)
if (prompt.text.trim())
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: prompt.text,
phase: "start",
messageID,
})
for (const commit of userImageCommits(messageID, prompt.files))
setFrame(child, sourceKey(messageID, commit.partID), commit)
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: value,
phase: "start",
messageID,
})
return true
}
@@ -330,14 +325,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
child.finishedTools.add(key)
child.tools.delete(key)
const partial = part.state.status === "error" && output
if (partial) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
setFrame(
child,
partial ? `${frame}:final` : frame,
toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory),
)
for (const commit of toolImageCommits(part, messageID)) setFrame(child, sourceKey(messageID, commit.partID), commit)
if (part.state.status === "error" && output) {
setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
setFrame(child, `${frame}:final`, toolCommit(part, messageID, "final", undefined, input.directory))
return
}
setFrame(child, frame, toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory))
}
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
@@ -349,7 +342,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const message of messages) {
if (message.type === "user") {
child.prompts.delete(message.id)
userFrame(child, message.id, message)
userFrame(child, message.id, message.text)
continue
}
if (message.type !== "assistant") continue
@@ -405,11 +398,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools)
let retry = false
const task = Promise.all([
sdk.message.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal }),
sdk.session.inbox.list({ sessionID: child.sessionID }, { signal }),
])
.then(([response, pending]) => {
const task = sdk.message
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal })
.then((response) => {
if (!active(signal)) return
const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID)
@@ -419,9 +410,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
for (const item of pending) {
if (item.type === "user") child.prompts.set(item.id, item.payload)
}
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
@@ -657,7 +645,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.inbox.enqueued") {
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload)
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload.text)
return
}
if (event.type === "session.inbox.delivered") {
+15 -59
View File
@@ -6,22 +6,19 @@ import type {
PermissionRequest,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageUser,
SessionInboxInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { formatContextUsage } from "../util/session"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { toolImageCommits, userImageCommits, type ImageCommit } from "./stream-v2.image"
import { messagePrompt } from "./session.shared"
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import { normalizeTool, toolOutputText } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
import type {
FooterApi,
FooterPatch,
FooterView,
LocalReplayRow,
MiniPermissionRequest,
@@ -126,17 +123,12 @@ type ToolState = {
started: boolean
}
type PendingPrompt = FooterQueuedPrompt & { files: SessionMessageUser["files"] }
type State = {
permissions: MiniPermissionRequest[]
forms: MiniFormRequest[]
globalForms: MiniFormRequest[]
view: FooterView
messageIDs: Set<string>
// Optimistic text and pending steers can be visible before attachment delivery.
promotedMessages: Set<string>
imageIDs: Set<string>
fragments: FragmentReconciler
tools: Map<string, ToolState>
toolSources: Map<string, SessionMessageAssistantTool>
@@ -155,12 +147,14 @@ type State = {
executionEpoch: number
buffered?: ReplayBuffer
errors: Set<string>
pending: Map<string, PendingPrompt>
pending: Map<string, FooterQueuedPrompt>
admitted: Set<string>
stepModel: RunInput["model"]
activeCompaction?: string
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message || error.name
@@ -190,12 +184,11 @@ function errorMessage(error: { message?: string; _tag?: string }) {
return error.message || error._tag || "Session execution failed"
}
function pendingPrompt(item: SessionInboxInfo): PendingPrompt | undefined {
function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
if (item.type !== "user") return undefined
return {
messageID: item.id,
prompt: { messageID: item.id, ...messagePrompt(item.payload) },
files: item.payload.files,
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
delivery: item.delivery,
...(item.payload.skills?.length
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
@@ -260,7 +253,6 @@ function promptFiles(next: SessionTurnInput) {
{
uri: part.url,
name: part.filename,
...(part.description === undefined ? {} : { description: part.description }),
mention: promptFileMention(part),
},
]
@@ -482,8 +474,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
globalForms: [],
view: { type: "prompt" },
messageIDs: new Set(),
promotedMessages: new Set(),
imageIDs: new Set(),
fragments: createFragmentReconciler(),
tools: new Map(),
toolSources: new Map(),
@@ -542,7 +532,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "idle", status })
}
const write = (commits: StreamCommit[], patch?: Pick<FooterPatch, "phase" | "status" | "usage">) => {
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
if (!state.initial && state.buffered === undefined)
commits.forEach((commit) => {
@@ -572,21 +562,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.footer.event({ type: "queued.prompts", prompts })
}
const freshImages = (commits: ImageCommit[], render = true) =>
commits.filter((commit) => {
const key = streamPartKey(commit.messageID, commit.partID)
if (state.imageIDs.has(key)) return false
state.imageIDs.add(key)
return render
})
const mergePending = (item: SessionInboxInfo) => {
const prompt = pendingPrompt(item)
if (!prompt) return
if (state.promotedMessages.has(prompt.messageID)) {
write(freshImages(userImageCommits(prompt.messageID, prompt.files)))
return
}
if (!prompt || state.messageIDs.has(prompt.messageID)) return
state.admitted.add(prompt.messageID)
state.pending.set(prompt.messageID, prompt)
syncPending()
@@ -677,14 +655,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.finishedTools.add(key)
state.tools.delete(key)
if (!sourcePending(key)) state.toolSources.delete(key)
const images = freshImages(toolImageCommits(part, messageID), render)
if (!render) return
const phase = toolFinalPhase(part)
if (part.state.status === "error" && delta)
write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
write([
toolCommit(part, messageID, phase, phase === "progress" ? delta : undefined, input.location?.directory, version),
...images,
])
}
@@ -695,17 +671,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.wait && (admitted || (waiting && state.wait.failureMessageID === message.id)))
promoteWait(state.wait, false, message.id)
if (state.pending.delete(message.id)) syncPending()
const visible = state.messageIDs.has(message.id) || (reuseVisibleWait && waiting)
if (state.messageIDs.has(message.id)) return
state.messageIDs.add(message.id)
state.promotedMessages.add(message.id)
const images = freshImages(userImageCommits(message.id, message.files), render)
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...(!visible ? skillCommits(message.id, message.skills) : []),
...(!visible && message.text.trim()
? [{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id } as const]
: []),
...images,
...skillCommits(message.id, message.skills),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
}
@@ -988,14 +960,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const waiting = state.wait?.messageID === event.data.inboxID
if (state.wait) promoteWait(state.wait, true, event.data.inboxID)
state.admitted.delete(event.data.inboxID)
state.promotedMessages.add(event.data.inboxID)
const pending = state.pending.get(event.data.inboxID)
state.pending.delete(event.data.inboxID)
syncPending()
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
if (!waiting && pending && !visible && pending.prompt.text.trim())
if (!waiting && pending && !visible)
commits.push({
kind: "user",
source: "system",
@@ -1003,7 +974,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
phase: "start",
messageID: event.data.inboxID,
})
if (pending) commits.push(...freshImages(userImageCommits(event.data.inboxID, pending.files)))
write(commits, { phase: "running", status: "waiting for assistant" })
return
}
@@ -1321,15 +1291,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
state.stepModel = undefined
const usage = total > 0 ? formatContextUsage(total, limit ? Math.round((total / limit) * 100) : undefined) : ""
write([], {
usage:
total > 0 || event.data.cost
? {
tokens: total,
percent: limit ? Math.round((total / limit) * 100) : undefined,
cost: event.data.cost || undefined,
}
: undefined,
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
})
return
}
@@ -1619,7 +1583,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!current(attempt)) return false
reset = true
state.messageIDs.clear()
state.imageIDs.clear()
state.fragments.clear()
state.tools.clear()
state.toolSources.clear()
@@ -1640,13 +1603,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
try {
if (reset) {
for (const row of localRows) {
if (row.commit.image && row.commit.messageID && row.commit.partID) {
const key = streamPartKey(row.commit.messageID, row.commit.partID)
if (state.imageIDs.has(key)) continue
state.imageIDs.add(key)
input.footer.append(row.commit)
continue
}
if (
row.commit.messageID &&
row.commit.partID &&
+461 -180
View File
@@ -1,13 +1,19 @@
// Theme resolution for direct interactive mode.
//
// Derives scrollback and footer colors from the terminal's actual palette.
// resolveRunTheme() queries the renderer for the terminal's palette,
// detects dark/light mode, builds a small system theme locally, and maps it to
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
// palette if detection fails.
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme } from "@opencode-ai/theme/tui"
import { allThemes, DEFAULT_THEMES, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
import { ansiToRgba } from "../theme/color"
import { discoverThemes } from "../theme/discovery"
import { generateSystem, terminalMode } from "../theme/system"
import { configDirectories } from "../util/config-directories"
import { dedupeWith } from "effect/Array"
import { resolveThemeColors } from "../theme/resolve"
import { terminalMode } from "../theme/system"
import type { Theme, ThemeV1Json } from "../theme/v1"
import type { EntryKind, RunTuiConfig } from "./types"
type ThemeCurrent = Omit<Theme, "_hasSelectedListItemText">
type Tone = {
body: ColorInput
start?: ColorInput
@@ -22,23 +28,15 @@ export type RunSplashTheme = {
}
export type RunFooterTheme = {
actionSecondaryText: ColorInput
actionFocusedBg: ColorInput
actionFocusedText: ColorInput
formfieldText: ColorInput
formfieldFocusedBg: ColorInput
formfieldFocusedText: ColorInput
selection: ColorInput
running: ColorInput
question: ColorInput
permission: ColorInput
success: ColorInput
link: ColorInput
categorical: ColorInput[]
highlight: ColorInput
selected: ColorInput
selectedText: ColorInput
warning: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
status: ColorInput
statusAccent: ColorInput
shade: ColorInput
surface: ColorInput
pane: ColorInput
@@ -69,22 +67,83 @@ export type RunTheme = {
block: RunBlockTheme
}
type ThemeColor = Exclude<keyof ThemeCurrent, "thinkingOpacity">
type SharedSyntaxTheme = ThemeCurrent & {
_hasSelectedListItemText: boolean
}
export const transparent = RGBA.fromValues(0, 0, 0, 0)
const ansiPalette = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index, ansiToRgba(index)))
const palettes = new WeakMap<CliRenderer, TerminalColors>()
function alpha(color: RGBA, value: number): RGBA {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
}
function rgba(hex: string, value?: number): RGBA {
const color = RGBA.fromHex(hex)
return value === undefined ? color : alpha(color, value)
}
function colorMode(bg: RGBA): "dark" | "light" {
return luminance(bg) > 0.5 ? "light" : "dark"
}
function luminance(color: RGBA): number {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
if (color.a === 0) {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
}
const target = Math.min(limit, color.a * scale)
const mix = Math.min(1, target / color.a)
return RGBA.fromValues(
base.r + (color.r - base.r) * mix,
base.g + (color.g - base.g) * mix,
base.b + (color.b - base.b) * mix,
color.a,
)
}
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
return RGBA.fromInts(
Math.round((base.r + (overlay.r - base.r) * value) * 255),
Math.round((base.g + (overlay.g - base.g) * value) * 255),
Math.round((base.b + (overlay.b - base.b) * value) * 255),
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
})
}
function srgbToLinear(value: number): number {
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
if (value <= 0.04045) {
return value / 12.92
}
return ((value + 0.055) / 1.055) ** 2.4
}
function oklab(color: RGBA) {
const r = srgbToLinear(color.r)
const g = srgbToLinear(color.g)
const b = srgbToLinear(color.b)
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
@@ -92,8 +151,8 @@ function oklab(color: RGBA) {
}
}
function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
const target = oklab(color)
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const target = oklab(rgba)
const hit = indexed.reduce(
(best, item) => {
const sample = oklab(item)
@@ -101,108 +160,352 @@ function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
const da = sample.a - target.a
const db = sample.b - target.b
const dist = dl * dl * 2 + da * da + db * db
return dist >= best.dist ? best : { dist, item }
if (dist >= best.dist) return best
return {
dist,
item,
}
},
{
dist: Number.POSITIVE_INFINITY,
item: indexed[0]!,
},
{ dist: Number.POSITIVE_INFINITY, item: indexed[0]! },
)
return RGBA.clone(hit.item)
}
function map(
theme: ResolvedTheme,
indexed: RGBA[],
mode: "light" | "dark",
syntax?: SyntaxStyle,
system = false,
): RunTheme {
const elevated = theme.contextual.elevated
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
const exact = (color: RGBA) => {
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
if (system && color.equals(theme.background.default)) return RGBA.defaultBackground(color)
return color
function paletteColor(colors: TerminalColors, index: number): RGBA {
const value = colors.palette[index]
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
}
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): ThemeCurrent {
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
return {
...resolved.theme,
thinkingOpacity: resolved.thinkingOpacity,
}
const scrollback = (color: RGBA) => {
const value = exact(color)
return value.a === 0 || value.intent !== "rgb" ? value : nearestIndexed(indexed, value)
}
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
const r = bg.r * 255
const g = bg.g * 255
const b = bg.b * 255
const lum = 0.299 * r + 0.587 * g + 0.114 * b
const cast = 0.25 * (1 - chroma(bg)) ** 2
const gray = (level: number) => {
const factor = level / 12
if (isDark && lum < 10) {
const value = Math.floor(factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
if (!isDark && lum > 245) {
const value = Math.floor(255 - factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
if (cast === 0) return map(tone)
const ratio = lum === 0 ? 0 : value / lum
return map(
tint(
tone,
RGBA.fromInts(
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
),
cast,
),
)
}
syntax?.getAllStyles().forEach((style, name) => {
syntax.registerStyle(name, {
...style,
fg: style.fg && scrollback(style.fg),
bg: style.bg && scrollback(style.bg),
})
})
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
}
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
const gray = isDark
? lum < 10
? 180
: Math.min(Math.floor(160 + lum * 0.3), 200)
: lum > 245
? 75
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
return map(RGBA.fromInts(gray, gray, gray))
}
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json {
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const bg = RGBA.defaultBackground(bg_snapshot)
const fg = RGBA.defaultForeground(fg_snapshot)
const isDark = pick === "dark"
const color = (index: number) => paletteColor(colors, index)
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
const ansi = {
red: color(1),
green: color(2),
yellow: color(3),
blue: color(4),
magenta: color(5),
cyan: color(6),
red_bright: color(9),
green_bright: color(10),
}
const diff_alpha = isDark ? 0.22 : 0.14
const diff_context_bg = grays[2]
const primary = ansi.cyan
const secondary = ansi.magenta
return {
background: RGBA.defaultBackground(theme.background.default),
footer: {
actionSecondaryText: exact(elevated.text.action.secondary.default),
actionFocusedBg: exact(elevated.background.action.primary.focused),
actionFocusedText: exact(elevated.text.action.primary.focused),
formfieldText: exact(elevated.text.formfield.default),
formfieldFocusedBg: exact(elevated.background.formfield.focused),
formfieldFocusedText: exact(elevated.text.formfield.focused),
selection: exact(elevated.text.formfield.selected),
running: exact(theme.text.status.running),
question: exact(theme.text.status.question),
permission: exact(theme.text.status.permission),
success: exact(theme.text.feedback.success.default),
link: exact(theme.markdown.link),
categorical: dedupeWith(
theme.categorical.map((scale) => exact(scale[mode === "light" ? 800 : 200])),
(a, b) => a.equals(b),
),
warning: exact(theme.text.feedback.warning.default),
error: exact(theme.text.feedback.error.default),
muted: exact(theme.text.subdued),
text: exact(theme.text.default),
shade: exact(elevated.background.default),
surface: exact(elevated.background.default),
pane: exact(theme.contextual.overlay.background.default),
border: exact(theme.border.default),
line: exact(theme.background.surface.overlay),
},
entry: {
system: { body: scrollback(theme.text.subdued) },
user: { body: scrollback(theme.text.default) },
assistant: { body: scrollback(theme.markdown.text) },
reasoning: { body: scrollback(theme.text.subdued) },
tool: { body: scrollback(theme.text.subdued), start: scrollback(theme.text.subdued) },
error: { body: scrollback(theme.text.feedback.error.default) },
},
splash: {
left: nearestIndexed(indexed, theme.text.subdued),
right: nearestIndexed(indexed, theme.text.default),
leftShadow: nearestIndexed(indexed, theme.background.surface.offset),
},
block: {
text: scrollback(theme.text.default),
muted: scrollback(theme.text.subdued),
syntax,
diffRemoved: scrollback(theme.diff.text.removed),
diffAddedBg: scrollback(theme.diff.background.added),
diffRemovedBg: scrollback(theme.diff.background.removed),
diffContextBg: scrollback(theme.diff.background.context),
diffHighlightAdded: scrollback(theme.diff.highlight.added),
diffHighlightRemoved: scrollback(theme.diff.highlight.removed),
diffLineNumber: scrollback(theme.diff.lineNumber.text),
diffAddedLineNumberBg: scrollback(theme.diff.lineNumber.background.added),
diffRemovedLineNumberBg: scrollback(theme.diff.lineNumber.background.removed),
theme: {
primary,
secondary,
accent: primary,
error: ansi.red,
warning: ansi.yellow,
success: ansi.green,
info: ansi.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: alpha(bg, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansi.green,
diffRemoved: ansi.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
markdownLinkText: ansi.cyan,
markdownCode: ansi.green,
markdownBlockQuote: ansi.yellow,
markdownEmph: ansi.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansi.blue,
markdownListEnumeration: ansi.cyan,
markdownImage: ansi.blue,
markdownImageText: ansi.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansi.magenta,
syntaxFunction: ansi.blue,
syntaxVariable: fg,
syntaxString: ansi.green,
syntaxNumber: ansi.yellow,
syntaxType: ansi.cyan,
syntaxOperator: ansi.cyan,
syntaxPunctuation: fg,
},
}
}
export const RUN_THEME_FALLBACK = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
ansiPalette,
"dark",
)
export const RUN_THEME_FALLBACK_LIGHT = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
ansiPalette,
"light",
)
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
return RGBA.clone(rgba)
}
return nearestIndexed(indexed, rgba)
}
function quantizeTheme(theme: ThemeCurrent, indexed: RGBA[]): ThemeCurrent {
const resolved = Object.fromEntries(
Object.entries(theme)
.filter(([key]) => key !== "thinkingOpacity")
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
thinkingOpacity: theme.thinkingOpacity,
}
}
function splashTheme(theme: ThemeCurrent, indexed: RGBA[]): RunSplashTheme {
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
return {
left,
right,
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
}
}
function map(
footerTheme: ThemeCurrent,
scrollbackTheme: ThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
): RunTheme {
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = colorMode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.13 : 0.06)
const statusAccentBase =
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
return {
background: footerTheme.background,
footer: {
highlight: footerTheme.primary,
selected: footerTheme.backgroundElement,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
error: footerTheme.error,
muted: footerTheme.textMuted,
text: footerTheme.text,
status,
statusAccent,
shade,
surface,
pane: footerTheme.backgroundMenu,
border: footerTheme.border,
line,
},
entry: {
system: {
body: scrollbackTheme.textMuted,
},
user: {
body: scrollbackTheme.primary,
},
assistant: {
body: scrollbackTheme.text,
},
reasoning: {
body: scrollbackTheme.textMuted,
},
tool: {
body: scrollbackTheme.text,
start: scrollbackTheme.textMuted,
},
error: {
body: scrollbackTheme.error,
},
},
splash,
block: {
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
diffContextBg: transparent,
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
diffLineNumber: scrollbackTheme.diffLineNumber,
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
},
}
}
const seed = {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
}
function tone(body: ColorInput, start?: ColorInput): Tone {
return {
body,
start,
}
}
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
const fallbackSplashLeft = RGBA.fromIndex(67)
const fallbackSplashRight = RGBA.fromIndex(110)
export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
error: seed.error,
muted: seed.muted,
text: seed.text,
status: tint(seed.panel, rgba("#000000"), 0.12),
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
border: seed.muted,
line: alpha(seed.panel, 0.96),
},
entry: {
system: tone(seed.muted),
user: tone(seed.highlight),
assistant: tone(seed.text),
reasoning: tone(seed.muted),
tool: tone(seed.text, seed.muted),
error: tone(seed.error),
},
splash: {
left: fallbackSplashLeft,
right: fallbackSplashRight,
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
},
block: {
text: seed.text,
muted: seed.muted,
diffRemoved: seed.error,
diffAddedBg: alpha(seed.success, 0.18),
diffRemovedBg: alpha(seed.error, 0.18),
diffContextBg: alpha(seed.panel, 0.72),
diffHighlightAdded: seed.success,
diffHighlightRemoved: seed.error,
diffLineNumber: seed.muted,
diffAddedLineNumberBg: alpha(seed.success, 0.12),
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
},
}
function monoTheme(mode: "dark" | "light"): RunTheme {
const foreground = RGBA.defaultForeground(mode === "light" ? "#000000" : "#ffffff")
@@ -210,23 +513,15 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
return {
background,
footer: {
actionSecondaryText: foreground,
actionFocusedBg: background,
actionFocusedText: foreground,
formfieldText: foreground,
formfieldFocusedBg: background,
formfieldFocusedText: foreground,
selection: foreground,
running: foreground,
question: foreground,
permission: foreground,
success: foreground,
link: foreground,
categorical: [foreground],
highlight: foreground,
selected: background,
selectedText: foreground,
warning: foreground,
error: foreground,
muted: foreground,
text: foreground,
status: background,
statusAccent: background,
shade: background,
surface: background,
pane: background,
@@ -234,14 +529,18 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
line: background,
},
entry: {
system: { body: foreground },
user: { body: foreground },
assistant: { body: foreground },
reasoning: { body: foreground },
tool: { body: foreground },
error: { body: foreground },
system: tone(foreground),
user: tone(foreground),
assistant: tone(foreground),
reasoning: tone(foreground),
tool: tone(foreground),
error: tone(foreground),
},
splash: {
left: foreground,
right: foreground,
leftShadow: background,
},
splash: { left: foreground, right: foreground, leftShadow: background },
block: {
text: foreground,
muted: foreground,
@@ -267,55 +566,37 @@ export async function resolveRunTheme(
mono = false,
): Promise<RunTheme> {
if (mono) {
const mode = renderer.themeMode ?? (await renderer.waitForThemeMode(300)) ?? config?.mode
const mode =
config?.mode === "light" || config?.mode === "dark"
? config.mode
: (renderer.themeMode ?? (await renderer.waitForThemeMode(300)))
return mode === "light" ? RUN_THEME_MONO_LIGHT : RUN_THEME_MONO
}
const detected = await renderer.getPalette({ size: 256 }).catch(() => undefined)
// A transient OSC timeout must not remap immutable scrollback against an unrelated ANSI palette.
const colors =
(detected?.defaultBackground ?? detected?.palette[0]) && (detected?.defaultForeground ?? detected?.palette[7])
? detected
: palettes.get(renderer)
if (colors) palettes.set(renderer, colors)
// Mini stays transparent: fresh OSC 11 wins, but cached colors must not override a new renderer mode.
const mode =
(detected && terminalMode(detected)) ??
renderer.themeMode ??
(colors && terminalMode(colors)) ??
(config?.mode === "light" ? "light" : "dark")
const name = config?.name ?? "opencode"
if (name === "system" && !colors) {
return mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK
}
const resolved = await themeSource(name, colors, mode)
.then((source) => {
const document = parseTheme(source, name)
// Mini keeps the terminal background, so the opposite theme mode is not a safe fallback.
if (themeModes(document).includes(mode)) return resolveThemeDocument(document, mode)
try {
const colors = await renderer.getPalette({
size: 256,
})
.catch(() => undefined)
const theme = resolved ?? resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const indexed = colors
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
: ansiPalette
return {
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
const bg = colors.defaultBackground ?? colors.palette[0]
if (!bg) {
return RUN_THEME_FALLBACK
}
// Palette-only terminal reloads can leave renderer.themeMode stale, but
// ANSI slot zero is not the terminal background when OSC 11 is absent.
const pick =
config?.mode === "dark" || config?.mode === "light"
? config.mode
: (terminalMode(colors) ?? renderer.themeMode ?? colorMode(RGBA.fromHex(bg)))
const { generateSyntax } = await import("../theme")
const indexed = indexedPalette(colors, 256)
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const syntaxTheme: SharedSyntaxTheme = {
...scrollbackTheme,
_hasSelectedListItemText: true,
}
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
} catch {
return RUN_THEME_FALLBACK
}
}
async function themeSource(
name: string,
colors: TerminalColors | undefined,
mode: "dark" | "light",
): Promise<ThemeDocumentSource> {
if (name === "system" && colors) return generateSystem(colors, mode)
const { Global } = await import("@opencode-ai/util/global")
const custom = await discoverThemes(
configDirectories(process.env.OPENCODE_CONFIG_DIR ?? Global.Path.config, process.cwd()),
)
const source = custom[name] ?? allThemes()[name] ?? DEFAULT_THEMES.opencode
return isThemeSource(source) ? source : DEFAULT_THEMES.opencode
}
+2 -9
View File
@@ -40,7 +40,6 @@ export type RunPromptPart =
url: string
filename?: string
mime?: string
description?: string
source?: {
type: string
text: { start: number; end: number; value: string }
@@ -120,7 +119,6 @@ export type RunInput = {
}
export type MiniHost = {
version: string
terminal: {
stdin: NodeJS.ReadStream
}
@@ -178,7 +176,7 @@ export type FooterState = {
status: string
notice: string
model: string
usage: { tokens: number; percent?: number; cost?: number } | undefined
usage: string
first: boolean
interrupt: number
exit: number
@@ -395,10 +393,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<
Config.Resolved,
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations"
>
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session" | "cursor">
export type MiniSettings = {
thinking: "show" | "hide"
@@ -406,7 +401,6 @@ export type MiniSettings = {
turn_summary: "show" | "hide"
footer: "show" | "hide"
splash: "show" | "hide"
work_spinner: Config.MiniWorkSpinner
mono: boolean
}
@@ -429,7 +423,6 @@ type StreamToolState = "running" | "completed" | "error"
export type StreamCommit = {
kind: EntryKind
text: string
image?: string
phase: StreamPhase
source: StreamSource
compaction?: true
-111
View File
@@ -1,111 +0,0 @@
import { octantGlyph } from "./subcell"
import type { Config } from "../config"
export type OneCellMotion = {
frames: string[]
interval: number
levels?: number[]
intro?: Pick<OneCellMotion, "frames" | "interval" | "levels">
once?: boolean
pace?: { initial: number; final: number; after: number; duration: number }
}
export function oneCellFrame(animation: OneCellMotion, elapsed: number) {
const introDuration = animation.intro ? animation.intro.frames.length * animation.intro.interval : 0
const intro = elapsed < introDuration
const clip = intro ? animation.intro! : animation
const pace = animation.pace
const progress = pace ? Math.max(0, Math.min(1, (elapsed - pace.after) / pace.duration)) : 0
// Integrate the changing rate so slowing down does not jump or restart the motion.
const time = intro
? elapsed
: !pace
? elapsed - introDuration
: (elapsed - introDuration) * pace.initial +
(pace.final - pace.initial) *
(pace.duration * (progress ** 3 - progress ** 4 / 2) + Math.max(0, elapsed - pace.after - pace.duration))
// Keep cycle boundaries exact when individual frame intervals are fractional.
const frame = Math.floor((time * clip.frames.length) / (clip.interval * clip.frames.length))
const index = !intro && animation.once ? Math.min(frame, clip.frames.length - 1) : frame % clip.frames.length
return {
glyph: clip.frames[index]!,
level: clip.levels?.[index] ?? 1,
complete: !intro && !!animation.once && frame >= clip.frames.length - 1,
}
}
export const SEED_WORK: OneCellMotion = {
frames: Array.from({ length: 40 }, () => "\u25aa"),
interval: 40,
levels: Array.from({ length: 40 }, (_, index) => 0.3 + (0.7 * (1 - Math.cos((index / 40) * 2 * Math.PI))) / 2),
intro: {
frames: Array.from({ length: 80 }, () => "\u25aa"),
interval: 40,
levels: Array.from({ length: 80 }, (_, index) => {
const phase = index / 80
return 0.3 + 0.7 * (phase < 0.125 ? phase / 0.125 : phase < 0.375 ? 1 : ((1 - phase) / 0.625) ** 3)
}),
},
pace: { initial: 1.25, final: 0.5, after: 30_000, duration: 30_000 },
}
const WORK_PACE = { initial: 1.2, final: 0.96, after: 30_000, duration: 30_000 }
const lower = [2, 3, 5, 7, 6, 4]
export const BLOCK_LOW_COMET: OneCellMotion = {
frames: lower.flatMap((point, index, path) => {
const leading = (1 << point) | (1 << path[(index + 5) % 6]!)
const full = leading | (1 << path[(index + 4) % 6]!)
return [full, full, full, leading, leading].map(octantGlyph)
}),
interval: 40,
pace: WORK_PACE,
}
export const BLOCK_SOFT_SWEEP: OneCellMotion = {
frames: [0x14, 0x14, 0x14, 0x1c, 0x3c, 0x38, 0x28, 0x28, 0x28, 0x38, 0x3c, 0x1c].map(octantGlyph),
interval: 100,
pace: WORK_PACE,
}
export const BLOCK_SOFT_SLIDE: OneCellMotion = {
frames: [0x14, 0x14, 0x14, 0x14, 0x14, 0x28, 0x28, 0x28, 0x28, 0x28].map(octantGlyph),
interval: 100,
levels: [0.55, 0.85, 1, 0.85, 0.55, 0.55, 0.85, 1, 0.85, 0.55],
}
export const WORK_SPINNERS = {
"block-soft-slide": BLOCK_SOFT_SLIDE,
"block-soft-sweep": BLOCK_SOFT_SWEEP,
"block-low-comet": BLOCK_LOW_COMET,
"block-low-duet": {
frames: lower.slice(0, 3).flatMap((point, index) => {
const heads = (1 << point) | (1 << lower[(index + 3) % 6]!)
const full = heads | (1 << lower[(index + 2) % 6]!) | (1 << lower[(index + 5) % 6]!)
return [full, full, full, heads].map(octantGlyph)
}),
interval: 60,
},
"block-shuttle": { frames: [0x14, 0x28].map(octantGlyph), interval: 500 },
"block-bridge": { frames: [0x14, 0x14, 0x14, 0x3c, 0x28, 0x28, 0x28, 0x3c].map(octantGlyph), interval: 120 },
"block-squeeze": {
frames: [0x14, 0x14, 0x14, 0x10, 0x20, 0x28, 0x28, 0x28, 0x20, 0x10].map(octantGlyph),
interval: 100,
},
"small-toggle": { frames: Array.from("\u25ab\u25aa"), interval: 320 },
"square-toggle": { frames: Array.from("\u25a1\u25a0"), interval: 500 },
"grow-shrink": { frames: Array.from("\u25ab\u25ab\u25aa\u25a0\u25aa"), interval: 180 },
"quadrant-orbit": { frames: Array.from("\u2598\u259d\u2597\u2596"), interval: 160 },
crosshatch: { frames: Array.from("\u25a7\u25a9\u25a8\u25a9"), interval: 240 },
"density-wave": { frames: Array.from("\u2591\u2591\u2591\u2592\u2593\u2588\u2593\u2592"), interval: 160 },
seed: SEED_WORK,
} satisfies Record<Config.MiniWorkSpinner, OneCellMotion>
export const SEED_LAUNCH: OneCellMotion = {
frames: Array.from({ length: 21 }, (_, index) => (index < 10 ? "\u25ab" : "\u25aa")),
interval: 40,
levels: SEED_WORK.levels!.slice(0, 21),
once: true,
}
export const SEED_MONO: OneCellMotion = { frames: ["-", "\\", "|", "/"], interval: 120 }
-37
View File
@@ -1,37 +0,0 @@
// Masks use row-major bits in a 2x4 grid. Unicode reuses these older glyphs
// instead of duplicating them in the otherwise mask-ordered octant block.
const OCTANTS = new Map<number, string>([
[0x00, " "],
[0x01, "\u{1cea8}"],
[0x02, "\u{1ceab}"],
[0x03, "\u{1fb82}"],
[0x05, "\u2598"],
[0x0a, "\u259d"],
[0x0f, "\u2580"],
[0x14, "\u{1fbe6}"],
[0x28, "\u{1fbe7}"],
[0x3f, "\u{1fb85}"],
[0x40, "\u{1cea3}"],
[0x50, "\u2596"],
[0x55, "\u258c"],
[0x5a, "\u259e"],
[0x5f, "\u259b"],
[0x80, "\u{1cea0}"],
[0xa0, "\u2597"],
[0xa5, "\u259a"],
[0xaa, "\u2590"],
[0xaf, "\u259c"],
[0xc0, "\u2582"],
[0xf0, "\u2584"],
[0xf5, "\u2599"],
[0xfa, "\u259f"],
[0xfc, "\u2586"],
[0xff, "\u2588"],
])
export function octantGlyph(mask: number) {
return (
OCTANTS.get(mask) ??
String.fromCodePoint(0x1cd00 + mask - [...OCTANTS.keys()].filter((value) => value < mask).length)
)
}
+1 -5
View File
@@ -17,16 +17,12 @@ test("validates the three explicit diff source defaults", () => {
expect(() => decodeInfo({ diffs: { source: "auto" } })).toThrow()
})
test("validates mini replay and work spinner settings", () => {
test("validates mini replay settings", () => {
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
})
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({
mini: { work_spinner: "quadrant-orbit" },
})
expect(() => decodeInfo({ mini: { work_spinner: "unknown" } })).toThrow()
})
test("validates the session tabs setting", () => {
@@ -1,731 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { Keymap } from "../../src/context/keymap"
import { RunFooter } from "../../src/mini/footer"
import { RunFormBody } from "../../src/mini/footer.form"
import { RunPermissionBody } from "../../src/mini/footer.permission"
import { RunFooterSubagentBody } from "../../src/mini/footer.subagent"
import { createFormBodyState } from "../../src/mini/form.shared"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
import type { FormReply, MiniFormRequest, MiniPermissionRequest, PermissionReply } from "../../src/mini/types"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const permission: MiniPermissionRequest = {
id: "per_responsive",
sessionID: "ses_responsive",
action: "shell",
resources: ["rm -rf /project/build/cache"],
save: ["/project/build/*", "/project/private/GRANT_SENTINEL"],
}
const form: MiniFormRequest = {
id: "frm_responsive",
sessionID: "ses_responsive",
title: "Deployment",
fields: [
{
key: "target",
type: "string",
title: "Target",
required: true,
options: Array.from({ length: 12 }, (_, index) => ({
value: `target-${index + 1}`,
label: `Target ${index + 1}`,
description: `Environment ${index + 1}`,
})),
},
],
}
function descendants(root: Renderable): Renderable[] {
return root.getChildren().flatMap((child) => [child, ...descendants(child)])
}
async function settle(app: Pick<Awaited<ReturnType<typeof testRender>>, "renderOnce">) {
await app.renderOnce()
await Bun.sleep(0)
await app.renderOnce()
await app.renderOnce()
}
async function renderForm(request: MiniFormRequest, width = 80, height = 20) {
const replies: FormReply[] = []
const app = await testRender(
() => (
<RunFormBody
request={request}
theme={RUN_THEME_FALLBACK.footer}
onReply={(reply) => {
replies.push(reply)
}}
onCancel={() => {}}
/>
),
{ width, height, kittyKeyboard: true },
)
return { ...app, replies }
}
test("roomy question forms retain the established spacing and inline descriptions", async () => {
const app = await renderForm({
...form,
title: "Questions",
fields: [
{
key: "layout",
type: "string",
title: "Layout",
description: "Which footer view?",
options: [
{ value: "form", label: "Form", description: "Inspect canonical form" },
{ value: "prompt", label: "Prompt", description: "Return to composer" },
],
},
{ key: "extra", type: "boolean", title: "Extra checks" },
],
})
try {
await settle(app)
for (const [text, x, y] of [
["Questions", 4, 1],
["1/2", 14, 1],
["Which footer view?", 2, 3],
["Form", 5, 5],
["Inspect canonical form", 10, 5],
] as const) {
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === text,
)!
expect({ text, x: node.x, y: node.y }).toEqual({ text, x, y })
}
expect(app.captureCharFrame().split("\n")[18]).toContain("↑↓ select enter choose tab next esc dismiss")
} finally {
app.renderer.destroy()
}
})
test.each([16, 24, 40])(
"complete questions and distinct option details remain readable at %i columns",
async (width) => {
const content = (app: Awaited<ReturnType<typeof renderForm>>) =>
app
.captureCharFrame()
.split("\n")
.map((row) => row.slice(0, width - 1))
.join(" ")
.replace(/\s+/g, " ")
const title = "Delete production database permanently?"
const app = await renderForm({ ...form, fields: [{ key: "yes", type: "boolean", title }] }, width, 8)
try {
await settle(app)
app.mockInput.pressKey("\x1b[5~")
await settle(app)
expect(content(app)).toContain(title)
expect(app.replies).toEqual([])
} finally {
app.renderer.destroy()
}
const options = await renderForm(
{
...form,
fields: [
{
key: "target",
type: "string",
title: "Target",
options: [
{ value: "stage", label: "Deploy api service to staging", description: "STAGING database" },
{ value: "prod", label: "Deploy api service to production", description: "PRODUCTION database" },
],
},
],
},
width,
12,
)
try {
await settle(options)
expect(content(options)).toContain("Deploy api service to staging")
expect(content(options)).toContain("STAGING database")
options.mockInput.pressArrow("down")
await settle(options)
expect(content(options)).toContain("Deploy api service to production")
expect(content(options)).toContain("PRODUCTION database")
options.mockInput.pressEnter()
await settle(options)
expect(options.replies[0]?.answer).toEqual({ target: "prod" })
} finally {
options.renderer.destroy()
}
},
)
test("advancing fields reveals the new choice without a second Enter", async () => {
const app = await renderForm(
{
...form,
fields: [
{ key: "confirm", type: "boolean", title: "Continue?" },
{
key: "target",
type: "string",
title: "Target",
description: "Select your preferred destination for this deployment and double-check it carefully.",
options: [
{ value: "b0", label: "B0" },
{ value: "b1", label: "B1" },
],
},
],
},
16,
8,
)
try {
await settle(app)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("B0")
expect(app.replies).toEqual([])
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Review")
} finally {
app.renderer.destroy()
}
})
test("roomy permission prompts retain their header and padded action bar", async () => {
const app = await testRender(
() => (
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
),
{ width: 112, height: 20 },
)
try {
await settle(app)
const nodes = descendants(app.renderer.root).filter(
(node): node is TextRenderable => node instanceof TextRenderable,
)
const title = nodes.find((node) => node.plainText.includes("Permission required"))!
const action = nodes.find((node) => node.plainText === "Allow once")!
expect({ x: title.x, y: title.y }).toEqual({ x: 2, y: 1 })
expect({ x: action.x, y: action.y }).toEqual({ x: 3, y: 18 })
expect(app.captureCharFrame().split("\n")[18]).toContain("⇆ select enter confirm esc reject")
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(scroll.x + scroll.width).toBe(109)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Waiting for permission event...")
} finally {
app.renderer.destroy()
}
})
test("roomy rejection keeps the editor and confirmation hints inline", async () => {
const app = await testRender(
() => (
<Keymap.Provider config={createTuiResolvedConfig()}>
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
</Keymap.Provider>
),
{ width: 112, height: 20, kittyKeyboard: true },
)
try {
await settle(app)
app.mockInput.pressArrow("right")
app.mockInput.pressArrow("right")
app.mockInput.pressEnter()
await settle(app)
const editor = app.renderer.currentFocusedEditor!
const confirm = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === "enter confirm",
)!
expect(editor.y).toBe(18)
expect(confirm.y).toBe(editor.y)
expect(confirm.x).toBeGreaterThanOrEqual(editor.x + editor.width)
expect(app.captureCharFrame()).toContain("esc cancel")
expect(app.captureCharFrame()).toContain("Tell OpenCode what to do differently")
} finally {
app.renderer.destroy()
}
})
test.each([false, true])("picked options budget their full ordinal and selection marker (mono=%s)", async (mono) => {
for (const width of [16, 112]) {
const description = width === 16 ? "prod" : "d".repeat(85) + "_END_LAST"
const app = await testRender(
() => (
<RunFormBody
request={{
...form,
fields: [
{
key: "target",
type: "string",
title: "Target",
default: "target-12",
options: Array.from({ length: 12 }, (_, index) => ({
value: `target-${index + 1}`,
label: "Deploy",
description,
})),
},
],
}}
theme={RUN_THEME_FALLBACK.footer}
mono={mono}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width, height: 20, kittyKeyboard: true },
)
try {
await settle(app)
const label = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === "Deploy *",
)!
const detail = label
.parent!.getChildren()
.find((node) => node instanceof TextRenderable && node.plainText === description)!
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(detail.y).toBeGreaterThan(label.y)
expect(detail.x + detail.width).toBeLessThanOrEqual(scroll.viewport.x + scroll.viewport.width - (mono ? 0 : 1))
expect(app.captureCharFrame()).toContain(description)
} finally {
app.renderer.destroy()
}
}
})
test.each([
{ width: 16, height: 8 },
{ width: 24, height: 8 },
{ width: 32, height: 8 },
{ width: 40, height: 8 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 80, height: 12 },
{ width: 112, height: 20 },
])("permission controls fit the allocated $width x $height viewport", async (size) => {
const replies: PermissionReply[] = []
const app = await testRender(
() => (
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={(reply) => {
replies.push(reply)
}}
/>
),
{ ...size, kittyKeyboard: true },
)
try {
await settle(app)
for (const label of ["Allow once", "Always allow", "Reject"]) {
expect(app.captureCharFrame()).toContain(label)
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === label,
)!
expect(node.x + node.width).toBeLessThanOrEqual(size.width)
expect(node.y + node.height).toBeLessThanOrEqual(size.height)
}
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(scroll.height).toBeGreaterThan(0)
expect(replies).toEqual([])
} finally {
app.renderer.destroy()
}
})
test.each(["once", "always", "reject"] as const)(
"24 x 8 production permission confirms the visible %s choice",
async (choice) => {
const app = await createTestRenderer({
width: 24,
height: 8,
screenMode: "split-footer",
footerHeight: 4,
kittyKeyboard: true,
})
const replies: PermissionReply[] = []
const footer = new RunFooter(app.renderer, {
directory: () => "/project",
findFiles: async () => [],
agents: [],
references: [],
agent: undefined,
modelLabel: "GPT-5",
model: undefined,
variant: undefined,
first: false,
theme: RUN_THEME_FALLBACK,
tuiConfig: createTuiResolvedConfig(),
miniSettings: {
current: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
footer: "show",
splash: "hide",
work_spinner: "block-soft-slide",
mono: false,
},
},
onPermissionReply: (reply) => {
replies.push(reply)
},
onFormReply: () => {},
onFormCancel: () => {},
onEditorOpen: async () => undefined,
subscribeThemeSignal: () => () => {},
})
try {
await settle(app)
footer.event({ type: "stream.view", view: { type: "permission", request: permission } })
await settle(app)
for (const label of ["Allow once", "Always allow", "Reject"]) expect(app.captureCharFrame()).toContain(label)
for (let index = 0; index < ["once", "always", "reject"].indexOf(choice); index++) app.mockInput.pressTab()
await settle(app)
const label = choice === "once" ? "Allow once" : choice === "always" ? "Always allow" : "Reject"
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === label,
)!
expect((node.parent as BoxRenderable).backgroundColor.toInts()).toEqual(
(RUN_THEME_FALLBACK.footer.actionFocusedBg as RGBA).toInts(),
)
expect(replies).toEqual([])
app.mockInput.pressEnter()
await settle(app)
if (choice !== "once") {
expect(replies).toEqual([])
expect(app.captureCharFrame()).toContain(choice === "always" ? "Confirm" : "enter reject")
app.mockInput.pressEnter()
await settle(app)
}
expect(replies).toEqual([{ sessionID: permission.sessionID, requestID: permission.id, reply: choice }])
} finally {
footer.destroy()
app.renderer.destroy()
}
},
)
test.each([
{ width: 16, height: 8 },
{ width: 24, height: 8 },
{ width: 32, height: 12 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 80, height: 20 },
])("form choices reveal selection at $width x $height", async (size) => {
const app = await renderForm(form, size.width, size.height)
try {
await settle(app)
for (let index = 0; index < 12; index++) {
if (index > 0) app.mockInput.pressKey("ARROW_DOWN")
await settle(app)
expect(app.captureCharFrame()).toContain(`Target ${index + 1}`)
const selected = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === `Target ${index + 1}`,
)!
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
expect(selected.y).toBeGreaterThanOrEqual(scroll.viewport.y)
expect(selected.y + selected.height).toBeLessThanOrEqual(scroll.viewport.y + scroll.viewport.height)
}
app.mockInput.pressEnter()
await settle(app)
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
} finally {
app.renderer.destroy()
}
})
test("form review scrolls from the first answer to the last", async () => {
const request: MiniFormRequest = {
...form,
fields: [
{ key: "field-1", type: "boolean", title: "Field 1", default: true },
...Array.from({ length: 11 }, (_, index) => ({
key: `field-${index + 2}`,
type: "boolean" as const,
title: `Field ${index + 2}`,
default: index % 2 !== 0,
})),
],
}
const app = await testRender(
() => (
<RunFormBody
request={request}
theme={RUN_THEME_FALLBACK.footer}
state={{ ...createFormBodyState(request), field: 12 }}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width: 24, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("Field 1: Yes")
expect(app.captureCharFrame()).not.toContain("Field 12: No")
app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).toContain("Field 12: No")
expect(app.captureCharFrame()).toContain("enter submit")
} finally {
app.renderer.destroy()
}
})
test("long permission paths, diffs and persistent scopes remain keyboard accessible", async () => {
const app = await testRender(
() => (
<RunPermissionBody
request={{
...permission,
action: "edit",
save: [...Array.from({ length: 8 }, (_, index) => `/project/resource-${index}/*`), ...permission.save!],
resources: ["/project/long/path/to/sensitive/configuration/auth.ts"],
metadata: {
diff: "--- a/auth.ts\n+++ b/auth.ts\n@@ -10000,2 +10000,2 @@\n-const allow = false\n+DIFF_SENTINEL\n return allow\n",
},
}}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
),
{ width: 24, height: 8 },
)
try {
await settle(app)
const frames = [app.captureCharFrame()]
for (let index = 0; index < 12; index++) {
app.mockInput.pressKey("\x1b[6~")
await settle(app)
frames.push(app.captureCharFrame())
}
expect(frames.join("\n")).toContain("+DIFF_SENTINEL")
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
const top = scroll.scrollTop
expect(top).toBeGreaterThan(0)
app.mockInput.pressKey("ARROW_RIGHT")
await settle(app)
expect(scroll.scrollTop).toBe(top)
app.mockInput.pressEnter()
await settle(app)
const scopes = [app.captureCharFrame()]
for (let index = 0; index < 12; index++) {
app.mockInput.pressKey("\x1b[6~")
await settle(app)
scopes.push(app.captureCharFrame())
}
expect(scopes.join("\n")).toContain("GRANT_SENTINEL")
expect(app.captureCharFrame()).toContain("Confirm")
expect(app.captureCharFrame()).toContain("Cancel")
const end = scroll.scrollTop
app.mockInput.pressKey("\x1b[5~")
await settle(app)
expect(scroll.scrollTop).toBeLessThan(end)
} finally {
app.renderer.destroy()
}
})
test("scrolling a choice offscreen reveals it before submission and survives resize", async () => {
const app = await renderForm(form, 24, 8)
try {
await settle(app)
app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).not.toMatch(/^1\. Target 1(?: |$)/m)
app.mockInput.pressEnter()
await settle(app)
expect(app.replies).toEqual([])
expect(app.captureCharFrame()).toMatch(/^1\. Target 1(?: |$)/m)
for (let index = 0; index < 11; index++) app.mockInput.pressKey("ARROW_DOWN")
await settle(app)
for (const size of [
{ width: 80, height: 20 },
{ width: 16, height: 8 },
{ width: 24, height: 8 },
]) {
app.resize(size.width, size.height)
await settle(app)
expect(app.captureCharFrame()).toContain("Target 12")
}
app.mockInput.pressEnter()
await settle(app)
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
} finally {
app.renderer.destroy()
}
})
test.each([16, 24, 80])("text form keeps its editor, error and controls at %s x 8", async (width) => {
const app = await testRender(
() => (
<Keymap.Provider config={createTuiResolvedConfig()}>
<RunFormBody
request={{
...form,
fields: [
{
key: "service",
type: "string",
title: "Service",
required: true,
description:
"Choose the service name for deployment. This name will be used to create production resources and route incoming requests to the correct application.",
},
],
}}
theme={RUN_THEME_FALLBACK.footer}
onReply={() => {}}
onCancel={() => {}}
/>
</Keymap.Provider>
),
{ width, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Answer required")
expect(app.captureCharFrame()).toContain("enter save")
expect(app.captureCharFrame()).toContain("esc dismiss")
const editor = app.renderer.currentFocusedEditor!
expect(editor.y).toBeGreaterThan(1)
expect(editor.y + editor.height).toBeLessThan(8)
await app.mockInput.typeText("api")
await settle(app)
expect(app.captureCharFrame()).toContain("api")
} finally {
app.renderer.destroy()
}
})
test("external form exposes its complete URL and state-specific actions", async () => {
const opened: string[] = []
const url = "https://identity.example.test/oauth/authorize?client_id=opencode&redirect_uri=URL_SENTINEL"
const app = await testRender(
() => (
<RunFormBody
request={{ ...form, fields: [{ key: "auth", type: "external", title: "Sign in", url }] }}
theme={RUN_THEME_FALLBACK.footer}
openExternal={async (value) => {
opened.push(value)
}}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width: 24, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("enter open URL")
expect(app.captureCharFrame()).not.toContain("choose")
for (let index = 0; index < 10; index++) app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).toContain("URL_SENTINEL")
app.mockInput.pressEnter()
await settle(app)
expect(opened).toEqual([url])
expect(app.captureCharFrame()).toContain("enter acknowledge")
} finally {
app.renderer.destroy()
}
})
test.each([false, true])("wrapped permission characters stay outside the scrollbar (mono=%s)", async (mono) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
const app = await testRender(
() => (
<RunPermissionBody
request={{ ...permission, resources: [alphabet] }}
theme={theme.footer}
block={theme.block}
mono={mono}
onReply={() => {}}
/>
),
{ width: 24, height: 20 },
)
try {
await settle(app)
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
const rows = app
.captureCharFrame()
.split("\n")
.slice(scroll.viewport.y, scroll.viewport.y + scroll.viewport.height)
expect(rows.map((row) => row.slice(0, mono ? 24 : 23).trim()).join("")).toContain(alphabet)
} finally {
app.renderer.destroy()
}
})
test.each([
{ width: 16, height: 8 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 112, height: 20 },
])("inspector keeps task identity and controls at $width x $height", async (size) => {
let closed = 0
const app = await testRender(
() => (
<RunFooterSubagentBody
active={() => true}
theme={() => RUN_THEME_FALLBACK}
tab={() => ({ sessionID: "child", label: "Explore", description: "Inspect authentication", status: "running" })}
index={() => 12}
total={() => 12}
detail={() => ({ commits: [{ kind: "system", source: "system", phase: "final", text: "Activity" }] })}
interrupt={() => "ctrl+d"}
onCycle={() => {}}
onClose={() => {
closed++
}}
/>
),
{ ...size, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("Inspect")
if (size.width < 56 || size.height < 12) expect(app.captureCharFrame()).toContain("esc back")
else expect(app.captureCharFrame()).toContain("12 of 12")
expect(app.captureCharFrame()).toContain("ctrl+d interrupt")
expect(app.captureCharFrame()).toContain("Activity")
app.mockInput.pressEscape()
await settle(app)
expect(closed).toBe(1)
} finally {
app.renderer.destroy()
}
})
+2 -5
View File
@@ -56,12 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits,
calls,
promptReady,
submit(text: string | RunPrompt, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt =
typeof text === "string"
? { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
: text
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
for (const fn of [...prompts]) fn(prompt)
return true
},
@@ -14,7 +14,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
status: "",
notice: "",
model: "gpt-5",
usage: undefined,
usage: "",
first: false,
interrupt: 0,
exit: 0,
@@ -57,6 +57,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
providers={() => undefined}
currentAgent={() => "Build"}
currentAgentID={() => "build"}
currentAgentExplicit={() => false}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
@@ -71,7 +72,6 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
turn_summary: "show",
footer: "show",
splash: "show",
work_spinner: "block-soft-slide",
mono: false,
})}
mono={false}
@@ -1,409 +0,0 @@
import { expect, test } from "bun:test"
import { RGBA, TextAttributes } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../src/mini/footer"
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
import type { RunPrompt } from "../../src/mini/types"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const sizes = [
[112, 30],
[24, 8],
[40, 12],
[112, 30],
] as const
async function setup(mono = false) {
const app = await createTestRenderer({
width: 112,
height: 30,
screenMode: "split-footer",
footerHeight: 4,
externalOutputMode: "capture-stdout",
})
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
const prompts: RunPrompt[] = []
const footer = new RunFooter(app.renderer, {
directory: () => "/project",
findFiles: async () => ["packages/tui/src/mini/footer.view.tsx", "packages/tui/src/mini/", "src/\u6587\u4ef6.ts"],
agents: [{ id: "build", name: "Build", mode: "primary", hidden: false }],
references: [],
agent: "build",
modelLabel: "GPT-5",
model: undefined,
variant: undefined,
first: false,
theme,
tuiConfig: createTuiResolvedConfig(),
miniSettings: {
current: { ...resolveMiniSettings(), mono },
},
onPermissionReply: () => {},
onFormReply: () => {},
onFormCancel: () => {},
onEditorOpen: async () => undefined,
subscribeThemeSignal: () => () => {},
})
footer.onPrompt((prompt) => prompts.push(prompt))
footer.event({ type: "catalog", agents: [], references: [], commands: [] })
return {
...app,
footer,
prompts,
selected() {
return app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) =>
mono
? (span.attributes & TextAttributes.INVERSE) !== 0
: span.bg.toInts().join() === (theme.footer.actionFocusedBg as RGBA).toInts().join(),
)
.map((span) => span.text)
.join("")
.trim()
},
async settle() {
await app.renderOnce()
await app.renderOnce()
},
cleanup() {
footer.destroy()
app.renderer.destroy()
},
}
}
test.each([56, 160])("production footer confirms exit with a visible menu hint at %i columns", async (width) => {
const app = await setup()
try {
app.resize(width, 30)
app.footer.event({ type: "stream.patch", patch: { phase: "running", usage: { tokens: 14100, percent: 1 } } })
await app.settle()
expect(app.captureCharFrame()).toContain("ctrl+p menu")
app.footer.requestExit()
await app.settle()
expect(app.captureCharFrame()).toContain("Press ctrl+c again to exit")
expect(app.captureCharFrame()).not.toContain("menu")
expect(app.footer.isClosed).toBe(false)
app.footer.requestExit()
expect(app.footer.isClosed).toBe(true)
} finally {
app.cleanup()
}
})
test.each([false, true])(
"production statusline restores identity and trailing menu after resizes (mono=%s)",
async (mono) => {
const app = await setup(mono)
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
try {
app.footer.event({
type: "models",
providers: [
{ id: "opencode", name: "Anomaly / OpenCode", models: { "gpt-5.6-sol": { name: "GPT-5.6 Sol (50% Off)" } } },
],
})
app.footer.event({
type: "model",
model: "GPT-5.6 Sol (50% Off)",
selection: { providerID: "opencode", modelID: "gpt-5.6-sol" },
})
app.footer.event({ type: "variants", variants: ["max"], current: "max" })
app.footer.event({ type: "stream.patch", patch: { usage: { tokens: 14100, percent: 1, cost: 0.04 } } })
await app.settle()
const initial = app.captureCharFrame()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
const statusline = app.renderer.root.findDescendantById("mini-statusline")!
const frame = app.captureCharFrame()
const row = frame.split("\n")[statusline.y].trimEnd()
const model = width === 112 ? "GPT-5.6 Sol (50% Off) [max]" : mono ? "GPT-5.6... [max]" : "GPT-5.6\u2026 [max]"
expect(row).toBe(
(width === 112
? ["Build", model, "14.1K (1%)", "$0.04", "Anomaly / OpenCode", "ctrl+p menu"]
: width === 40
? ["Build", model, "1% ctx"]
: ["Build", model]
).join(mono ? " - " : " \u00b7 "),
)
expect(statusline.height).toBe(1)
expect(app.renderer.footerHeight).toBe(height === 30 ? 4 : 2)
const identity = app.captureSpans().lines[statusline.y].spans.find((span) => span.text.includes("[max]"))!
expect(identity.text).toContain(model)
expect(identity.fg.toInts()).toEqual((theme.footer.text as RGBA).toInts())
if (width === 112) expect(frame).toBe(initial)
if (mono) expect(frame).not.toMatch(/[^\x00-\x7f]/)
}
} finally {
app.cleanup()
}
},
)
test.each([false, true])("production command menu keeps navigation visible across resizes (mono=%s)", async (mono) => {
const app = await setup(mono)
try {
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
app.mockInput.pressKey("END")
await app.settle()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
expect(app.captureCharFrame()).toContain("esc")
expect(app.selected()).toContain("Exit")
expect(app.footer.isClosed).toBe(false)
}
for (const [key, title] of [
["ARROW_UP", "Settings"],
["ARROW_DOWN", "Exit"],
["HOME", "Open editor"],
["ARROW_DOWN", "Show status"],
["END", "Exit"],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(title)
}
app.mockInput.pressEnter()
expect(app.footer.isClosed).toBe(true)
} finally {
app.cleanup()
}
})
test.each([false, true])(
"production model menu keeps labels and current state before metadata (mono=%s)",
async (mono) => {
const app = await setup(mono)
const title = "\u6a21\u578b\u6d4b\u8bd5 Alpha"
try {
app.footer.event({
type: "models",
providers: [
{
id: "provider-with-long-id",
name: "Provider with a very long name",
models: Object.fromEntries(
Array.from({ length: 22 }, (_, index) => [
"very-long-internal-model-id-" + index,
{ name: index === 0 ? title : `Model ${String(index).padStart(2, "0")}` },
]),
),
},
],
})
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("switch model")
app.mockInput.pressEnter()
await app.settle()
for (const [key, text] of [
["HOME", "Model 01"],
["ARROW_DOWN", "Model 02"],
["ARROW_UP", "Model 01"],
["END", title],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(text)
}
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.selected()).toContain(title)
expect(app.captureCharFrame()).toContain("esc")
}
app.resize(24, 8)
await app.settle()
expect(app.selected()).toBe(title)
expect(app.captureCharFrame()).not.toContain("very-long")
app.mockInput.pressEnter()
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("switch model")
app.mockInput.pressEnter()
await app.settle()
expect(app.selected()).toContain("current")
expect(app.selected()).toContain("\u6a21\u578b\u6d4b\u8bd5")
expect(app.selected()).not.toContain("very-long")
for (const width of [16, 20, 32, 40]) {
app.resize(width, 8)
await app.settle()
expect(app.captureCharFrame()).toContain("esc")
expect(app.selected()).toContain(title)
expect(app.selected().includes("current")).toBe(width >= 24)
}
} finally {
app.cleanup()
}
},
)
test("subagent menu recalculates its twelve-row window after shrink, filter, and growth", async () => {
const app = await setup()
try {
app.footer.event({
type: "stream.subagent",
state: {
tabs: Array.from({ length: 24 }, (_, index) => ({
sessionID: `child-${index}`,
label: "Worker",
description: `Task ${String(index).padStart(2, "0")}`,
status: "running",
})),
details: {},
permissions: [],
forms: [],
},
})
await app.settle()
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
for (const [key, title] of [
["END", "Task 23"],
["ARROW_UP", "Task 22"],
["HOME", "Task 00"],
["ARROW_DOWN", "Task 01"],
["END", "Task 23"],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(title)
}
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
expect(app.selected()).toContain("Task 23")
expect(app.selected()).toContain("running")
}
await app.mockInput.typeText("Task 23")
await app.settle()
expect(app.selected()).toContain("Task 23")
app.mockInput.pressKey("u", { ctrl: true })
await app.settle()
app.mockInput.pressKey("HOME")
for (const [height, title] of [
[8, "Task 04"],
[12, "Task 08"],
[30, "Task 11"],
] as const) {
app.resize(24, height)
await app.settle()
app.mockInput.pressKey("\x1b[6~")
await app.settle()
expect(app.selected()).toContain(title)
app.mockInput.pressKey("\x1b[5~")
await app.settle()
expect(app.selected()).toContain("Task 00")
}
} finally {
app.cleanup()
}
})
test.each([false, true])("wrapped notices reserve space beside a six-line draft (mono=%s)", async (mono) => {
const app = await setup(mono)
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
try {
await app.settle()
app.mockInput.pasteBracketedText(draft)
app.footer.event({ type: "stream.patch", patch: { notice: "failed to save settings" } })
for (const width of [16, 24, 40]) {
app.resize(width, 8)
await app.flush()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(7)
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
expect(app.captureCharFrame().replace(/\s+/g, " ")).toContain("failed to save settings")
expect(app.captureCharFrame()).toContain("sixth")
}
app.footer.event({ type: "stream.patch", patch: { notice: "" } })
await app.flush()
expect(app.captureCharFrame()).toContain("Build")
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
} finally {
app.cleanup()
}
})
test.each([false, true])("composer and autocomplete share the physical height budget (mono=%s)", async (mono) => {
const app = await setup(mono)
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
try {
await app.settle()
app.mockInput.pasteBracketedText(draft)
await app.flush()
await app.mockInput.typeText(" @f")
await app.settle()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
expect(app.captureCharFrame()).toContain("sixth @f")
expect(app.selected()).toContain(width === 24 ? "@mini/footer.view.tsx" : "footer.view.tsx")
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
expect(app.selected()).toContain("mini/")
app.mockInput.pressKey("ARROW_UP")
await app.settle()
expect(app.selected()).toContain("footer.view.tsx")
}
app.resize(24, 8)
await app.settle()
app.mockInput.pressEnter()
await app.settle()
const text = draft + " @packages/tui/src/mini/footer.view.tsx "
expect(app.renderer.currentFocusedEditor?.plainText).toBe(text)
app.mockInput.pressEnter()
await app.settle()
expect(app.prompts).toHaveLength(1)
expect(app.prompts[0].text).toBe(text)
expect(app.prompts[0].parts[0]).toMatchObject({
type: "file",
filename: "packages/tui/src/mini/footer.view.tsx",
source: {
type: "file",
path: "packages/tui/src/mini/footer.view.tsx",
text: {
start: draft.length + 1,
end: text.length - 1,
value: "@packages/tui/src/mini/footer.view.tsx",
},
},
})
app.footer.event({
type: "catalog",
agents: [],
references: [],
commands: Array.from({ length: 24 }, (_, index) => ({
name: `cmd-${String(index).padStart(2, "0")}`,
description: "Optional command description",
})),
})
await app.mockInput.typeText("/")
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
for (let index = 1; index <= 12; index++) {
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
expect(app.selected()).toContain(`/cmd-${String(index).padStart(2, "0")}`)
}
for (let index = 0; index < 12; index++) app.mockInput.pressKey("ARROW_UP")
await app.settle()
expect(app.selected()).toContain("/cmd-00")
}
} finally {
app.cleanup()
}
})

Some files were not shown because too many files have changed in this diff Show More