diff --git a/CHANGELOG.md b/CHANGELOG.md index ca445455ec..834d3c3e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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 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 ### Sessions diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 562fa54a12..58444247be 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: 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/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..d780a38321 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..6c3add3315 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..6f4c1ceec8 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 { resolveSpinner, type Spinner } 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: 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)" }), }) 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).toEqual(SpinnerDefault) + expect(SpinnerDefault.frames).toEqual(["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"]) + expect(SpinnerDefault.interval).toBe(230) +}) + +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).toEqual(SpinnerDefault) + expect(resolveSpinner("hasOwnProperty")).toEqual(SpinnerDefault) +}) + +test("accepts any string in the schema and rejects non-strings", () => { + expect(decodeInfo({ spinner: "anything" })).toMatchObject({ spinner: "anything" }) + expect(() => decodeInfo({ spinner: 5 })).toThrow() +})