From 5cd819918767911a3900a3e76d636d5e6ffc83bd Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 26 Jul 2026 23:26:06 +0700 Subject: [PATCH 1/4] feat(tui): configurable loading spinner, default aesthetic The thinking/loading spinner (Thinking header, running tool/task/bash rows, dialogs, startup screen, and the run-CLI subagent inspector) changes from braille dots to the cli-spinners "aesthetic" bar. A new optional "spinner" key in tui.json accepts any cli-spinners preset name; unknown names fall back to aesthetic without invalidating the config. Reduced-motion behavior is unchanged. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++++++++ DIVERGENCES.md | 4 +++ bun.lock | 2 ++ package.json | 1 + .../src/cli/cmd/run/footer.subagent.tsx | 6 ++--- .../opencode/src/cli/cmd/run/footer.view.tsx | 1 + .../opencode/src/cli/cmd/run/runtime.boot.ts | 1 + packages/opencode/src/cli/cmd/run/types.ts | 2 +- packages/tui/package.json | 1 + packages/tui/src/component/spinner.tsx | 4 ++- packages/tui/src/config/index.tsx | 9 ++++++- packages/tui/src/config/spinner.ts | 11 ++++++++ packages/tui/test/spinner-config.test.tsx | 26 +++++++++++++++++++ 13 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 packages/tui/src/config/spinner.ts create mode 100644 packages/tui/test/spinner-config.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ca445455ec..dccd0a04ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why. ## Unreleased +### TUI + +- **New loading spinner + `spinner` config key** — the thinking/loading spinner + (Thinking header, running tool/task/bash rows, dialogs, startup screen, and + the run-CLI subagent inspector) changed from braille dots to the + [cli-spinners](https://github.com/sindresorhus/cli-spinners) `aesthetic` bar + (`▰▰▰▱▱▱▱`). Set `"spinner": ""` in tui.json to any cli-spinners preset + name (e.g. `"dots"` restores the old look); unknown names fall back to + `aesthetic` without invalidating the config. With animations disabled the + static `⋯` indicator is unchanged. + ## 1.18.3-1 ### Sessions diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 562fa54a12..29f652f319 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -159,6 +159,10 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. | | `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | +| `packages/tui/src/config/index.tsx` | Added optional `spinner` key (`Schema.String`) to `Info`; `Resolved` gains required `spinner: SpinnerName` (added to the `Omit` list); `resolve()` sets it via `resolveSpinnerName` from the fork-only `src/config/spinner.ts` (validates against the cli-spinners map, defaults to `aesthetic`) | User-configurable loading spinner preset. Permissive string schema on purpose: host-side `TuiConfig.load()` drops the entire tui.json on schema failure, so a typo'd name under a literal union would nuke the user's whole config; the runtime fallback degrades gracefully instead (same pattern as `theme`). | Keep the schema key, the `Resolved` field + `Omit` entry, the `./spinner` import, and the `resolve()` line; take upstream's surrounding changes. | +| `packages/tui/src/component/spinner.tsx` | `Spinner` renders `` (preset frames + interval come from cli-spinners via opentui-spinner's `name` prop) instead of the hardcoded braille `frames={SPINNER_FRAMES} interval={80}`; added the `useTuiConfig` import. `SPINNER_FRAMES` export retained but no longer used here. | Default loading spinner is the cli-spinners `aesthetic` bar, user-configurable via the `spinner` key in tui.json. | Keep the `name=` render + the `useTuiConfig` import/call; take upstream's surrounding changes. If upstream adds new frame constants or intervals, they're superseded by the config-driven preset. | +| `packages/opencode/src/cli/cmd/run/types.ts`, `packages/opencode/src/cli/cmd/run/runtime.boot.ts`, `packages/opencode/src/cli/cmd/run/footer.view.tsx`, `packages/opencode/src/cli/cmd/run/footer.subagent.tsx` | Threaded the resolved spinner preset into the run-CLI subagent inspector: the `RunTuiConfig` Pick gains `"spinner"`, `runTuiConfig()` copies `config.spinner`, footer.view passes `spinner={props.tuiConfig.spinner}`, and footer.subagent renders `` instead of the imported `SPINNER_FRAMES` + `interval={80}` (that import is dropped). | The subagent-inspector spinner matches the TUI's configured (default `aesthetic`) preset instead of hardcoded braille dots. | Keep the Pick member, the copied field, the prop, and the `name=` render; take upstream's surrounding changes. If upstream reworks the subagent footer, re-thread `tuiConfig.spinner` into whatever renders the spinner. | +| `package.json` (root, catalog), `packages/tui/package.json` | Added `"cli-spinners": "3.4.0"` to the root catalog and `"cli-spinners": "catalog:"` to tui's dependencies | The spinner config validates names against the cli-spinners preset map; the package was previously only a transitive dep of `opentui-spinner` (not importable under Bun's isolated installs). Pinned to the version opentui-spinner already resolves so both share one instance. | Keep our lines; take upstream's other fields. | ## Known rebrand gaps diff --git a/bun.lock b/bun.lock index 83b51846ed..b94618f835 100644 --- a/bun.lock +++ b/bun.lock @@ -964,6 +964,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "cli-spinners": "catalog:", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", @@ -1097,6 +1098,7 @@ "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", "ai": "6.0.168", + "cli-spinners": "3.4.0", "cross-spawn": "7.0.6", "diff": "8.0.2", "dompurify": "3.3.1", diff --git a/package.json b/package.json index 9fc9063cd5..5aa24869f0 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "effect": "4.0.0-beta.83", "ai": "6.0.168", "cross-spawn": "7.0.6", + "cli-spinners": "3.4.0", "hono": "4.10.7", "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index a755b18cd3..ae5f7dcea9 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -3,9 +3,8 @@ import type { ScrollBoxRenderable } from "@opentui/core" import { useKeyboard } from "@opentui/solid" import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" import { Show, createMemo, indexArray } from "solid-js" -import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" import { RunEntryContent, separatorRows } from "./scrollback.writer" -import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types" +import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle, RunTuiConfig } from "./types" import type { RunFooterTheme, RunTheme } from "./theme" registerOpencodeSpinner() @@ -53,6 +52,7 @@ export function RunFooterSubagentBody(props: { detail: () => FooterSubagentDetail | undefined width: () => number diffStyle?: RunDiffStyle + spinner: RunTuiConfig["spinner"] onCycle: (dir: -1 | 1) => void onClose: () => void }) { @@ -128,7 +128,7 @@ export function RunFooterSubagentBody(props: { {current().status === "running" ? ( - + ) : ( diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index d334ebd251..2c64556d48 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -935,6 +935,7 @@ export function RunFooterView(props: RunFooterViewProps) { detail={detail} width={width} diffStyle={props.diffStyle} + spinner={props.tuiConfig.spinner} onCycle={cycleTab} onClose={closeTab} /> diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index b1f6217846..ac538b5ebb 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -82,6 +82,7 @@ function runTuiConfig(config: Config | undefined): RunTuiConfig { keybinds: config.keybinds, leader_timeout: config.leader_timeout, diff_style: config.diff_style ?? "auto", + spinner: config.spinner, } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e48..fb30c0e880 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -286,7 +286,7 @@ export type QuestionReply = Parameters[0] export type QuestionReject = Parameters[0] -export type RunTuiConfig = Pick +export type RunTuiConfig = Pick // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. diff --git a/packages/tui/package.json b/packages/tui/package.json index ef7ea6cf7b..87a798857f 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -56,6 +56,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "cli-spinners": "catalog:", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index f648a605a6..8e89e33de1 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -1,4 +1,5 @@ import { Show } from "solid-js" +import { useTuiConfig } from "../config" import { useTheme } from "../context/theme" import { useKV } from "../context/kv" import type { JSX } from "@opentui/solid" @@ -12,11 +13,12 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { const { theme } = useTheme() const kv = useKV() + const tuiConfig = useTuiConfig() const color = () => props.color ?? theme.textMuted return ( ⋯ {props.children}}> - + {props.children} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index df9239763a..3a3d7acd4a 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -4,6 +4,7 @@ import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" import { createContext, type JSX, useContext } from "solid-js" import { TuiKeybind } from "./keybind" +import { resolveSpinnerName, type SpinnerName } from "./spinner" export const AttentionSoundName = Schema.Literals([ "default", @@ -62,11 +63,15 @@ export const Info = Schema.Struct({ scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), + spinner: Schema.optional(Schema.String).annotate({ + description: + "Loading spinner: any cli-spinners preset name, e.g. 'aesthetic', 'dots', 'line' (default: aesthetic; unknown names fall back to the default)", + }), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) export type Info = Schema.Schema.Type -export type Resolved = Omit & { +export type Resolved = Omit & { attention: { enabled: boolean notifications: boolean @@ -78,6 +83,7 @@ export type Resolved = Omit { + expect(resolve({}, { terminalSuspend: true }).spinner).toBe("aesthetic") + expect(SpinnerDefault).toBe("aesthetic") +}) + +test("passes valid cli-spinners preset names through", () => { + expect(resolve({ spinner: "dots" }, { terminalSuspend: true }).spinner).toBe("dots") + expect(resolveSpinnerName("moon")).toBe("moon") +}) + +test("falls back to the default for unknown spinner names", () => { + expect(resolve({ spinner: "not-a-spinner" }, { terminalSuspend: true }).spinner).toBe("aesthetic") + expect(resolveSpinnerName("hasOwnProperty")).toBe("aesthetic") +}) + +test("accepts any string in the schema and rejects non-strings", () => { + expect(decodeInfo({ spinner: "anything" })).toMatchObject({ spinner: "anything" }) + expect(() => decodeInfo({ spinner: 5 })).toThrow() +}) From 8c919c07bbe78472ffe5f2ff881220918ceac9b0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 26 Jul 2026 23:35:14 +0700 Subject: [PATCH 2/4] feat(tui): narrow the default spinner to 3 segments The default is now a fork-owned {frames, interval} spec (aesthetic's fill cycle at 3 segments instead of 7); the spinner config resolves to a spec rather than a preset name so the default doesn't have to be a cli-spinners preset. Named presets in tui.json behave as before. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++++------ DIVERGENCES.md | 6 +++--- .../src/cli/cmd/run/footer.subagent.tsx | 2 +- packages/tui/src/component/spinner.tsx | 2 +- packages/tui/src/config/index.tsx | 8 ++++---- packages/tui/src/config/spinner.ts | 15 +++++++++----- packages/tui/test/spinner-config.test.tsx | 20 ++++++++++--------- 7 files changed, 37 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dccd0a04ce..834d3c3e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,13 @@ file is the human-readable summary of what changed and why. - **New loading spinner + `spinner` config key** — the thinking/loading spinner (Thinking header, running tool/task/bash rows, dialogs, startup screen, and - the run-CLI subagent inspector) changed from braille dots to the - [cli-spinners](https://github.com/sindresorhus/cli-spinners) `aesthetic` bar - (`▰▰▰▱▱▱▱`). Set `"spinner": ""` in tui.json to any cli-spinners preset - name (e.g. `"dots"` restores the old look); unknown names fall back to - `aesthetic` without invalidating the config. With animations disabled the - static `⋯` indicator is unchanged. + the run-CLI subagent inspector) changed from braille dots to a 3-segment + filling bar (`▰▰▱`, styled after + [cli-spinners](https://github.com/sindresorhus/cli-spinners)' `aesthetic`). + Set `"spinner": ""` in tui.json to any cli-spinners preset name (e.g. + `"dots"` restores the old look); unknown names fall back to the default bar + without invalidating the config. With animations disabled the static `⋯` + indicator is unchanged. ## 1.18.3-1 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 29f652f319..58444247be 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -159,9 +159,9 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. | | `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | -| `packages/tui/src/config/index.tsx` | Added optional `spinner` key (`Schema.String`) to `Info`; `Resolved` gains required `spinner: SpinnerName` (added to the `Omit` list); `resolve()` sets it via `resolveSpinnerName` from the fork-only `src/config/spinner.ts` (validates against the cli-spinners map, defaults to `aesthetic`) | User-configurable loading spinner preset. Permissive string schema on purpose: host-side `TuiConfig.load()` drops the entire tui.json on schema failure, so a typo'd name under a literal union would nuke the user's whole config; the runtime fallback degrades gracefully instead (same pattern as `theme`). | Keep the schema key, the `Resolved` field + `Omit` entry, the `./spinner` import, and the `resolve()` line; take upstream's surrounding changes. | -| `packages/tui/src/component/spinner.tsx` | `Spinner` renders `` (preset frames + interval come from cli-spinners via opentui-spinner's `name` prop) instead of the hardcoded braille `frames={SPINNER_FRAMES} interval={80}`; added the `useTuiConfig` import. `SPINNER_FRAMES` export retained but no longer used here. | Default loading spinner is the cli-spinners `aesthetic` bar, user-configurable via the `spinner` key in tui.json. | Keep the `name=` render + the `useTuiConfig` import/call; take upstream's surrounding changes. If upstream adds new frame constants or intervals, they're superseded by the config-driven preset. | -| `packages/opencode/src/cli/cmd/run/types.ts`, `packages/opencode/src/cli/cmd/run/runtime.boot.ts`, `packages/opencode/src/cli/cmd/run/footer.view.tsx`, `packages/opencode/src/cli/cmd/run/footer.subagent.tsx` | Threaded the resolved spinner preset into the run-CLI subagent inspector: the `RunTuiConfig` Pick gains `"spinner"`, `runTuiConfig()` copies `config.spinner`, footer.view passes `spinner={props.tuiConfig.spinner}`, and footer.subagent renders `` instead of the imported `SPINNER_FRAMES` + `interval={80}` (that import is dropped). | The subagent-inspector spinner matches the TUI's configured (default `aesthetic`) preset instead of hardcoded braille dots. | Keep the Pick member, the copied field, the prop, and the `name=` render; take upstream's surrounding changes. If upstream reworks the subagent footer, re-thread `tuiConfig.spinner` into whatever renders the spinner. | +| `packages/tui/src/config/index.tsx` | Added optional `spinner` key (`Schema.String`) to `Info`; `Resolved` gains required `spinner: Spinner` — a `{frames, interval}` spec (added to the `Omit` list); `resolve()` sets it via `resolveSpinner` from the fork-only `src/config/spinner.ts` (known cli-spinners names resolve to their preset spec; anything else falls back to the fork's default, a 3-segment aesthetic-style bar) | User-configurable loading spinner preset. Permissive string schema on purpose: host-side `TuiConfig.load()` drops the entire tui.json on schema failure, so a typo'd name under a literal union would nuke the user's whole config; the runtime fallback degrades gracefully instead (same pattern as `theme`). Resolving to a spec (not a name) lets the default be a fork-owned animation that isn't a cli-spinners preset. | Keep the schema key, the `Resolved` field + `Omit` entry, the `./spinner` import, and the `resolve()` line; take upstream's surrounding changes. | +| `packages/tui/src/component/spinner.tsx` | `Spinner` renders `` instead of the hardcoded braille `frames={SPINNER_FRAMES} interval={80}`; added the `useTuiConfig` import. `SPINNER_FRAMES` export retained but no longer used here. | Default loading spinner is a 3-segment aesthetic-style bar (`▰▰▱`), user-configurable to any cli-spinners preset via the `spinner` key in tui.json. | Keep the config-driven `frames`/`interval` render + the `useTuiConfig` import/call; take upstream's surrounding changes. If upstream adds new frame constants or intervals, they're superseded by the config-driven spec. | +| `packages/opencode/src/cli/cmd/run/types.ts`, `packages/opencode/src/cli/cmd/run/runtime.boot.ts`, `packages/opencode/src/cli/cmd/run/footer.view.tsx`, `packages/opencode/src/cli/cmd/run/footer.subagent.tsx` | Threaded the resolved spinner spec into the run-CLI subagent inspector: the `RunTuiConfig` Pick gains `"spinner"`, `runTuiConfig()` copies `config.spinner`, footer.view passes `spinner={props.tuiConfig.spinner}`, and footer.subagent renders `` instead of the imported `SPINNER_FRAMES` + `interval={80}` (that import is dropped). | The subagent-inspector spinner matches the TUI's configured (default 3-segment bar) spec instead of hardcoded braille dots. | Keep the Pick member, the copied field, the prop, and the `frames`/`interval` render; take upstream's surrounding changes. If upstream reworks the subagent footer, re-thread `tuiConfig.spinner` into whatever renders the spinner. | | `package.json` (root, catalog), `packages/tui/package.json` | Added `"cli-spinners": "3.4.0"` to the root catalog and `"cli-spinners": "catalog:"` to tui's dependencies | The spinner config validates names against the cli-spinners preset map; the package was previously only a transitive dep of `opentui-spinner` (not importable under Bun's isolated installs). Pinned to the version opentui-spinner already resolves so both share one instance. | Keep our lines; take upstream's other fields. | ## Known rebrand gaps diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index ae5f7dcea9..d780a38321 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -128,7 +128,7 @@ export function RunFooterSubagentBody(props: { {current().status === "running" ? ( - + ) : ( diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index 8e89e33de1..6c3add3315 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -18,7 +18,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { return ( ⋯ {props.children}}> - + {props.children} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 3a3d7acd4a..6f4c1ceec8 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -4,7 +4,7 @@ import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" import { createContext, type JSX, useContext } from "solid-js" import { TuiKeybind } from "./keybind" -import { resolveSpinnerName, type SpinnerName } from "./spinner" +import { resolveSpinner, type Spinner } from "./spinner" export const AttentionSoundName = Schema.Literals([ "default", @@ -65,7 +65,7 @@ export const Info = Schema.Struct({ diff_style: Schema.optional(DiffStyle), spinner: Schema.optional(Schema.String).annotate({ description: - "Loading spinner: any cli-spinners preset name, e.g. 'aesthetic', 'dots', 'line' (default: aesthetic; unknown names fall back to the default)", + "Loading spinner: any cli-spinners preset name, e.g. 'aesthetic', 'dots', 'line' (default: a 3-segment aesthetic-style bar; unknown names fall back to the default)", }), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) @@ -83,7 +83,7 @@ export type Resolved = Omit { - expect(resolve({}, { terminalSuspend: true }).spinner).toBe("aesthetic") - expect(SpinnerDefault).toBe("aesthetic") +test("defaults spinner to the 3-segment aesthetic-style bar", () => { + expect(resolve({}, { terminalSuspend: true }).spinner).toEqual(SpinnerDefault) + expect(SpinnerDefault.frames).toEqual(["▰▱▱", "▰▰▱", "▰▰▰", "▰▱▱"]) + expect(SpinnerDefault.interval).toBe(80) }) -test("passes valid cli-spinners preset names through", () => { - expect(resolve({ spinner: "dots" }, { terminalSuspend: true }).spinner).toBe("dots") - expect(resolveSpinnerName("moon")).toBe("moon") +test("resolves valid cli-spinners preset names to their spec", () => { + expect(resolve({ spinner: "dots" }, { terminalSuspend: true }).spinner).toEqual(spinners.dots) + expect(resolveSpinner("moon")).toEqual(spinners.moon) }) test("falls back to the default for unknown spinner names", () => { - expect(resolve({ spinner: "not-a-spinner" }, { terminalSuspend: true }).spinner).toBe("aesthetic") - expect(resolveSpinnerName("hasOwnProperty")).toBe("aesthetic") + expect(resolve({ spinner: "not-a-spinner" }, { terminalSuspend: true }).spinner).toEqual(SpinnerDefault) + expect(resolveSpinner("hasOwnProperty")).toEqual(SpinnerDefault) }) test("accepts any string in the schema and rejects non-strings", () => { From 1080e28f1bf6232f936d8eeb0c2492810cff0f0c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 26 Jul 2026 23:37:42 +0700 Subject: [PATCH 3/4] fix(tui): slow the default spinner and start it empty The 3-segment bar now cycles empty -> full (4 frames at 160ms, the same 640ms full cycle as cli-spinners' aesthetic) instead of holding the first segment filled at 80ms. Co-Authored-By: Claude Fable 5 --- packages/tui/src/config/spinner.ts | 7 ++++--- packages/tui/test/spinner-config.test.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/config/spinner.ts b/packages/tui/src/config/spinner.ts index 0d5233a754..58e848cac3 100644 --- a/packages/tui/src/config/spinner.ts +++ b/packages/tui/src/config/spinner.ts @@ -2,10 +2,11 @@ import spinners, { type Spinner, type SpinnerName } from "cli-spinners" export type { Spinner, SpinnerName } -// cli-spinners' "aesthetic" narrowed from 7 segments to 3 (same fill cycle and pace). +// cli-spinners' "aesthetic" narrowed from 7 segments to 3, starting from empty; +// 4 frames at 160ms keep the full cycle at aesthetic's 640ms. export const SpinnerDefault: Spinner = { - interval: 80, - frames: ["▰▱▱", "▰▰▱", "▰▰▰", "▰▱▱"], + interval: 160, + frames: ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"], } // Permissive on purpose: an unknown name falls back to the default instead of diff --git a/packages/tui/test/spinner-config.test.tsx b/packages/tui/test/spinner-config.test.tsx index 62ab03e42b..a52ef0e106 100644 --- a/packages/tui/test/spinner-config.test.tsx +++ b/packages/tui/test/spinner-config.test.tsx @@ -8,8 +8,8 @@ const decodeInfo = Schema.decodeUnknownSync(Info) test("defaults spinner to the 3-segment aesthetic-style bar", () => { expect(resolve({}, { terminalSuspend: true }).spinner).toEqual(SpinnerDefault) - expect(SpinnerDefault.frames).toEqual(["▰▱▱", "▰▰▱", "▰▰▰", "▰▱▱"]) - expect(SpinnerDefault.interval).toBe(80) + expect(SpinnerDefault.frames).toEqual(["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"]) + expect(SpinnerDefault.interval).toBe(160) }) test("resolves valid cli-spinners preset names to their spec", () => { From 5f74e65efb90ce0913d05e8114f3b37685d32da8 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 26 Jul 2026 23:40:26 +0700 Subject: [PATCH 4/4] fix(tui): slow the default spinner to 230ms per frame Co-Authored-By: Claude Fable 5 --- packages/tui/src/config/spinner.ts | 5 ++--- packages/tui/test/spinner-config.test.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/config/spinner.ts b/packages/tui/src/config/spinner.ts index 58e848cac3..42543f55ea 100644 --- a/packages/tui/src/config/spinner.ts +++ b/packages/tui/src/config/spinner.ts @@ -2,10 +2,9 @@ import spinners, { type Spinner, type SpinnerName } from "cli-spinners" export type { Spinner, SpinnerName } -// cli-spinners' "aesthetic" narrowed from 7 segments to 3, starting from empty; -// 4 frames at 160ms keep the full cycle at aesthetic's 640ms. +// cli-spinners' "aesthetic" narrowed from 7 segments to 3, starting from empty. export const SpinnerDefault: Spinner = { - interval: 160, + interval: 230, frames: ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"], } diff --git a/packages/tui/test/spinner-config.test.tsx b/packages/tui/test/spinner-config.test.tsx index a52ef0e106..1cd0db5739 100644 --- a/packages/tui/test/spinner-config.test.tsx +++ b/packages/tui/test/spinner-config.test.tsx @@ -9,7 +9,7 @@ const decodeInfo = Schema.decodeUnknownSync(Info) test("defaults spinner to the 3-segment aesthetic-style bar", () => { expect(resolve({}, { terminalSuspend: true }).spinner).toEqual(SpinnerDefault) expect(SpinnerDefault.frames).toEqual(["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"]) - expect(SpinnerDefault.interval).toBe(160) + expect(SpinnerDefault.interval).toBe(230) }) test("resolves valid cli-spinners preset names to their spec", () => {