Compare commits

..

2 Commits

Author SHA1 Message Date
Dax Raad b246824fdb chore remove snapshot install smoke test 2026-05-15 18:37:19 -04:00
Dax Raad e9db6c4209 fix npm cli binary installation 2026-05-15 18:33:39 -04:00
21 changed files with 82 additions and 440 deletions
-1
View File
@@ -7,7 +7,6 @@ on:
- ci
- dev
- beta
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
inputs:
-27
View File
@@ -63,33 +63,6 @@ jobs:
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run OpenTUI crash diagnostics
if: runner.os == 'Windows'
continue-on-error: true
working-directory: packages/opencode
run: |
set -euxo pipefail
bun --revision
bun -e 'console.log({ platform: process.platform, arch: process.arch, versions: process.versions })'
bun -e 'console.log("@opentui/core-win32-x64", (await import("@opentui/core-win32-x64")).default)'
bun -e 'import { EditBuffer } from "@opentui/core"; let cursor = 0; let content = 0; for (let i = 0; i < 5000; i++) { const b = EditBuffer.create("unicode"); b.on("cursor-changed", () => cursor++); b.on("content-changed", () => content++); b.setText(`draft-${i}`); await Bun.sleep(0); b.destroy(); } console.log({ cursor, content })'
for i in 1 2 3 4 5; do
echo "dialog-prompt targeted run $i"
bun test --timeout 30000 test/cli/tui/dialog-prompt.test.tsx
done
set +e
run_case() {
name="$1"
shift
echo "OpenTUI subset diagnostics: $name"
bun test --timeout 30000 "$@"
code=$?
echo "OpenTUI subset diagnostics exit code for $name: $code"
}
run_case "cli/run + dialog" test/cli/run test/cli/tui/dialog-prompt.test.tsx
run_case "cli/cmd/tui + cli/run + dialog" test/cli/cmd/tui test/cli/run test/cli/tui/dialog-prompt.test.tsx
run_case "cli + dialog" test/cli test/cli/tui/dialog-prompt.test.tsx
- name: Run unit tests
run: bun turbo test:ci
env:
+15 -15
View File
@@ -536,9 +536,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.12",
"@opentui/keymap": ">=0.2.12",
"@opentui/solid": ">=0.2.12",
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
},
"optionalPeers": [
"@opentui/core",
@@ -721,9 +721,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.12",
"@opentui/keymap": "0.2.12",
"@opentui/solid": "0.2.12",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.12", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.12", "@opentui/core-darwin-x64": "0.2.12", "@opentui/core-linux-arm64": "0.2.12", "@opentui/core-linux-x64": "0.2.12", "@opentui/core-win32-arm64": "0.2.12", "@opentui/core-win32-x64": "0.2.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2QEPM0MmyOl6gnEFx/lICiOIMEUl4A6pARge1unT4E5EPb1WPAhy4Qdfs/13PfkicJErpZs3ZxoWGFfyFYKpyw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KUNh3sy8yJAa5KzzVCsgVBVDeChOsCUD8hS3Zr6/BIFhLy01qfrh30SY1rje2OeLrZFrNJKRc1SFjGMZOPab9Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-3aMIAE5COo4Qg3f+mDvuxIaVMq9EIOimt/6xOZ9dTPMIKQ/rsj2MFbMn3k0VkSk0xyJilBLH//qMML3AvgQccQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-ijhZs0DhO7Ueb5hFh2am/6hYbofwup5hmvVO9Q4WS2J23pdLfzVlG6gk+8bAGuByJrMEpD5iSYOsLEAcT8PmQg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xqwyirkJLXEzOb6ur/Mke3gUy/3IJVnd38E1KWMfXd/CduhIfOk+KC+XcZE62DBhnwN8yMZh7567mmfpA1mn3A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-WWgIexRAXSzA4/QN8IIzJ08QrRaobKS4GRe8engKrZKHbpiuuDCpbSW2OmZrCDfF88sQOoTYP038rCK8+IYxvw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-ERQRT/0fEDYWyEWs/YGx1pNkXvkslsqe4pDFPW2Bx4FefFuJs+3p3LUtj+BiH7kNHpST4Lq4r6xKu6t4/LdTlQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.12", "", { "dependencies": { "@opentui/core": "0.2.12" }, "peerDependencies": { "@opentui/react": "0.2.12", "@opentui/solid": "0.2.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-OzyBZJcw/NPdW/2yprrudVfVYXSOmU+nx//i1pFWPjo7oXYtp1qv1bdy5MQs7AxaSWN32NWrgVbzpY15YPkMiQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
"@opentui/solid": ["@opentui/solid@0.2.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-HjF57m+jCj09a6jZpmRnnTnFemkwSmfkP5zBL/vlEjZnKjQrpRSPzErUMLDhfyGK7X9AA8aFGtCiqd5B5X4FpQ=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
}
}
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.12",
"@opentui/keymap": "0.2.12",
"@opentui/solid": "0.2.12",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1
View File
@@ -38,6 +38,7 @@ export const Flag = {
),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"],
-84
View File
@@ -70,54 +70,11 @@ Endpoint definitions declare which public errors can be emitted. Public
HTTP error schemas carry their response status with `httpApiStatus` or the
equivalent HttpApi schema annotation.
Effect's own HttpApi examples follow this pattern:
```ts
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<
Authorization,
{
provides: CurrentUser
}
>()("app/Authorization", {
security: { bearer: HttpApiSecurity.bearer },
error: Unauthorized,
}) {}
```
Endpoint-level errors use the same idea:
```ts
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
{
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
HttpApiEndpoint.get("get", "/config", {
success: Config.Info,
error: ConfigApiError,
})
```
The service error and HTTP error may be the same class only when the wire
shape is intentionally public. Use separate HTTP error schemas when the
service error contains internals, low-level causes, retry hints, or data
that should not be exposed to API clients.
Do not map every domain error into one universal HTTP error class. Prefer a
small public error vocabulary by route group: shared shapes like
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
contract.
## Mapping Guidance
- Keep one-off translations inline in the handler.
@@ -129,35 +86,6 @@ contract.
breaking API change.
- Use built-in `HttpApiError.*` only when its generated body and SDK
surface are intentionally the public contract.
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
not the same as the internal domain error shape.
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
errors that are naturally tagged by `_tag`.
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
the public API error schema instead of relying on `NamedError.toObject()` in
generic middleware.
## User-Facing Rendering
HTTP serialization and user rendering are separate boundaries. The server
should send structured public errors; CLI and TUI code should format those
structures through one shared formatter.
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
decoded response body in an `Error`. The original body should remain available
under `error.cause.body`; `FormatError` is the right place to unwrap and render
that body. TUI aggregation helpers should call `FormatError` first, then fall
back to generic `Error.message` / string rendering.
When several parallel startup requests fail from the same underlying issue,
group identical rendered messages and list the affected request names once.
For example:
```text
Configuration is invalid at /path/to/opencode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get
```
## Middleware Guidance
@@ -171,15 +99,6 @@ middleware should shrink. It should not gain new name checks.
Unknown `500` responses should log full details server-side with
`Cause.pretty(cause)` and return a safe public body.
The config startup regression in #27056 is the failure mode this rule is meant
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
generic `UnknownError`. The compatibility fix is to preserve config parse and
validation errors as client-visible `400`s. The target architecture is better:
config loading should fail on the typed error channel, config HTTP handlers
should map those errors to declared `ConfigApiError` responses, and the generic
middleware should never see them.
## Migration Order
Prefer small vertical slices:
@@ -194,9 +113,6 @@ Prefer small vertical slices:
Good early domains are storage not-found, worktree errors, and provider
auth validation errors because they currently drive HTTP behavior.
Config parse and validation errors are also a good early slice because they
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
## Checklist For A PR
- [ ] Expected failures are typed errors, not defects.
@@ -188,7 +188,6 @@ export const Definitions = {
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
@@ -1,5 +1,3 @@
import { FormatError } from "@/cli/error"
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
@@ -17,19 +15,7 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
)
if (failed.length === 0) return null
const reasons = Array.from(
failed
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
.reduce((grouped, failure) => {
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
return grouped
}, new Map<string, string[]>())
.entries(),
)
.map(([message, names]) =>
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
)
.join("; ")
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
@@ -37,9 +23,6 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
}
function reasonMessage(reason: unknown): string {
const formatted = FormatError(reason)
if (formatted) return formatted
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
@@ -1,9 +1,10 @@
import { createMemo, type Setter } from "solid-js"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "hide"
export type ThinkingMode = "show" | "minimal" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
@@ -19,7 +20,7 @@ export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value)
}
// Cycle order matches the slash command: show → hide → show.
// Cycle order matches the slash command: show → minimal → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show"
@@ -32,7 +33,7 @@ export function useThinkingMode() {
// The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "minimal")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the
@@ -46,21 +47,21 @@ export function useThinkingMode() {
// Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "hide" default (collapsed thinking).
// get the new "minimal" default.
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => {
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
const value = stored()
return isThinkingMode(value) ? value : "hide"
return isThinkingMode(value) ? value : "minimal"
})
return {
mode,
set,
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
}
}
@@ -392,7 +392,7 @@ function AssistantReasoning(props: {
const thinking = useThinkingMode()
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
const inMinimal = createMemo(() => thinking.mode() === "hide")
const inMinimal = createMemo(() => thinking.mode() === "minimal")
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined)
@@ -404,7 +404,7 @@ function AssistantReasoning(props: {
}
return (
<Show when={content()}>
<Show when={content() && thinking.mode() !== "hide"}>
<Switch>
<Match when={!inMinimal() || expanded()}>
<box
@@ -218,7 +218,7 @@ export function Session() {
const [conceal, setConceal] = createSignal(true)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const showThinking = createMemo(() => true)
const showThinking = createMemo(() => thinkingMode() !== "hide")
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -689,8 +689,9 @@ export function Session() {
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
if (next === "hide") return "Collapse thinking"
return "Expand thinking"
if (next === "minimal") return "Switch thinking to minimal"
if (next === "hide") return "Hide thinking"
return "Show thinking"
})(),
value: "session.toggle.thinking",
category: "Session",
@@ -699,6 +700,16 @@ export function Session() {
aliases: ["toggle-thinking"],
},
run: () => {
// Env override forces minimal for the process. Updating KV here would
// silently diverge from what's rendered; tell the user instead.
if (thinking.locked()) {
toast.show({
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
variant: "info",
})
dialog.clear()
return
}
thinking.set(nextThinkingMode(thinkingMode()))
dialog.clear()
},
@@ -1501,7 +1512,7 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// Collapsed by default in minimal mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
@@ -1512,7 +1523,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
// Flips independently of the parent message completing.
const isDone = createMemo(() => props.part.time.end !== undefined)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
const duration = createMemo(() => {
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
@@ -1528,10 +1539,10 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
}
return (
<Show when={content()}>
<Show when={content() && ctx.thinkingMode() !== "hide"}>
<Switch>
<Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
<box
id={"text-" + props.part.id}
paddingLeft={2}
@@ -1547,7 +1558,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={(inMinimal() ? "▼ " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
@@ -1,10 +1,8 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../context/tui-config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = {
title: string
@@ -20,32 +18,8 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
function confirm() {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}
useBindings(() => ({
target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer.
priority: 1,
commands: [
{
name: "dialog.prompt.submit",
title: "Submit dialog prompt",
category: "Dialog",
run: confirm,
},
],
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
@@ -85,10 +59,13 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description}
<textarea
onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
@@ -103,11 +80,9 @@ export function DialogPrompt(props: DialogPromptProps) {
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={submitShortcut()}>
<text fg={theme.text}>
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</box>
</box>
+15 -14
View File
@@ -2,9 +2,16 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record"
interface ErrorLike {
name?: string
_tag?: string
message?: string
data?: Record<string, unknown>
}
type ConfigIssue = { message: string; path: string[] }
function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
function isTaggedError(error: unknown, tag: string): boolean {
return isRecord(error) && error._tag === tag
}
@@ -32,27 +39,22 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
: []
}
export function FormatError(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = FormatError(input.cause.body)
if (formatted) return formatted
}
export function FormatError(input: unknown) {
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
if (isTaggedError(input, "CliError")) {
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return stringField(input, "message") ?? ""
const data = input as ErrorLike & { exitCode?: number }
if (data.exitCode != null) process.exitCode = data.exitCode
return data.message ?? ""
}
// MCPFailed: { name: string }
if (NamedError.hasName(input, "MCPFailed")) {
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
}
// AccountServiceError, AccountTransportError: TaggedErrorClass
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
return stringField(input, "message") ?? ""
return (input as ErrorLike).message ?? ""
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
@@ -62,7 +64,7 @@ export function FormatError(input: unknown): string | undefined {
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [
`Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
@@ -110,7 +112,6 @@ export function FormatError(input: unknown): string | undefined {
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
}
return undefined
}
export function FormatUnknownError(input: unknown): string {
@@ -1,6 +1,5 @@
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigError } from "@/config/error"
import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
@@ -19,13 +18,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
if (!defect) return Effect.failCause(cause)
const error = defect.defect
if (
error instanceof NamedError &&
(ConfigError.InvalidError.isInstance(error) || ConfigError.JsonError.isInstance(error))
) {
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
}
log.error("failed", { error, cause: Cause.pretty(cause) })
return Effect.succeed(
@@ -5,7 +5,6 @@
*/
import { describe, expect, test } from "bun:test"
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
import { ConfigError } from "@/config/error"
describe("aggregateFailures", () => {
test("returns null when every result is fulfilled", () => {
@@ -42,48 +41,11 @@ describe("aggregateFailures", () => {
expect(err!.message).toContain("agents: boom")
})
test("formats structured config errors hidden inside SDK error causes", () => {
const configError = new ConfigError.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
const err = aggregateFailures([
{
name: "config.get",
result: {
status: "rejected",
reason: new Error("ConfigInvalidError", {
cause: {
body: configError.toObject(),
},
}),
},
},
])
expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json")
expect(err!.message).toContain("Expected object provider.anthropic.options")
})
test("deduplicates identical failure messages across startup requests", () => {
const reason = new Error("same config problem")
const err = aggregateFailures([
{ name: "config.providers", result: { status: "rejected", reason } },
{ name: "provider.list", result: { status: "rejected", reason } },
{ name: "app.agents", result: { status: "rejected", reason } },
{ name: "config.get", result: { status: "rejected", reason } },
{ name: "project.sync", result: { status: "fulfilled", value: undefined } },
])
expect(err!.message).toContain("4 of 5 requests failed: same config problem")
expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get")
expect(err!.message.match(/same config problem/g)?.length).toBe(1)
})
test("attaches structured failure list under .cause", () => {
const reason = new Error("nope")
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] })
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
expect(cause.failures).toEqual([{ name: "providers", reason }])
})
test("falls back to String() for opaque reasons", () => {
@@ -1,146 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { TextareaRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function mountPrompt(input: {
root: string
keybinds: Partial<TuiKeybind.Keybinds>
onConfirm: (value: string) => void
}) {
const { Global } = await import("@opencode-ai/core/global")
const previous = {
config: Global.Path.config,
state: Global.Path.state,
}
Global.Path.config = path.join(input.root, "config")
Global.Path.state = path.join(input.root, "state")
await mkdir(Global.Path.config, { recursive: true })
await mkdir(Global.Path.state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
const [
{ DialogProvider },
{ DialogPrompt },
{ KVProvider },
{ ThemeProvider },
{ TuiConfigProvider },
{ ToastProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
] = await Promise.all([
import("../../../src/cli/cmd/tui/ui/dialog"),
import("../../../src/cli/cmd/tui/ui/dialog-prompt"),
import("../../../src/cli/cmd/tui/context/kv"),
import("../../../src/cli/cmd/tui/context/theme"),
import("../../../src/cli/cmd/tui/context/tui-config"),
import("../../../src/cli/cmd/tui/ui/toast"),
import("../../../src/cli/cmd/tui/keymap"),
])
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const resolvedConfig = createTuiResolvedConfig({
keybinds: input.keybinds,
leader_timeout: 1000,
})
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
onCleanup(off)
return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={resolvedConfig}>
<KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
return {
app,
async cleanup() {
app.renderer.destroy()
Global.Path.config = previous.config
Global.Path.state = previous.state
},
}
}
test("dialog prompt submit wins when return is also input newline", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
input_submit: "super+return",
input_newline: "return,shift+return,alt+return,ctrl+j",
},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter()
expect(confirmed).toEqual(["draft"])
expect(textarea.plainText).toBe("draft")
} finally {
await prompt.cleanup()
}
})
test("dialog prompt submit can be rebound separately from input submit", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
input_submit: "return",
"dialog.prompt.submit": "ctrl+y",
},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter()
expect(confirmed).toEqual([])
expect(textarea.plainText).toBe("draft")
prompt.app.mockInput.pressKey("y", { ctrl: true })
expect(confirmed).toEqual(["draft"])
} finally {
await prompt.cleanup()
}
})
@@ -470,7 +470,6 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
which_key_toggle: "alt+k",
editor_open: "ctrl+e",
"prompt.autocomplete.next": "ctrl+j",
"dialog.prompt.submit": "ctrl+s",
"dialog.mcp.toggle": "ctrl+t",
model_favorite_toggle: "ctrl+f",
"dialog.plugins.install": "shift+i",
@@ -492,7 +491,6 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
)
expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
expect(config.keybinds.get("dialog.prompt.submit")?.[0]?.key).toBe("ctrl+s")
expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
@@ -1,7 +1,6 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { NamedError } from "@opencode-ai/core/util/error"
import { describe, expect } from "bun:test"
import { ConfigError } from "../../src/config/error"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error"
@@ -51,27 +50,6 @@ describe("HttpApi error middleware", () => {
}),
)
it.live("preserves config defects as client-visible bad requests", () =>
Effect.gen(function* () {
const configError = new ConfigError.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe(
Layer.provide(errorLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(400)
expect(JSON.stringify(body)).toBe(JSON.stringify(configError.toObject()))
}),
)
it.live("does not map storage not-found defects to 404", () =>
Effect.gen(function* () {
yield* HttpRouter.add(
+3 -3
View File
@@ -22,9 +22,9 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.2.12",
"@opentui/keymap": ">=0.2.12",
"@opentui/solid": ">=0.2.12"
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -145,7 +145,6 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
"dialog.select.home": "home",
"dialog.select.end": "end",
"dialog.select.submit": "return",
"dialog.prompt.submit": "return",
"dialog.mcp.toggle": "space",
"prompt.autocomplete.prev": "up,ctrl+p",
"prompt.autocomplete.next": "down,ctrl+n",