Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<name>"` 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
Expand Down
4 changes: 4 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<spinner frames={useTuiConfig().spinner.frames} interval={….interval}>` 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 `<spinner frames={props.spinner.frames} interval={props.spinner.interval}>` 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

Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}) {
Expand Down Expand Up @@ -128,7 +128,7 @@ export function RunFooterSubagentBody(props: {
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
{current().status === "running" ? (
<box flexShrink={0}>
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
<spinner frames={props.spinner.frames} interval={props.spinner.interval} color={statusColor(footer(), current().status)} />
</box>
) : (
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/run/footer.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,7 @@ export function RunFooterView(props: RunFooterViewProps) {
detail={detail}
width={width}
diffStyle={props.diffStyle}
spinner={props.tuiConfig.spinner}
onCycle={cycleTab}
onClose={closeTab}
/>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/run/runtime.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/run/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]

export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]

export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style" | "spinner">

// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
Expand Down
1 change: 1 addition & 0 deletions packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"cli-spinners": "catalog:",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
Expand Down
4 changes: 3 additions & 1 deletion packages/tui/src/component/spinner.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<spinner frames={tuiConfig.spinner.frames} interval={tuiConfig.spinner.interval} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
Expand Down
9 changes: 8 additions & 1 deletion packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<typeof Info>

export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse" | "spinner"> & {
attention: {
enabled: boolean
notifications: boolean
Expand All @@ -78,6 +83,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" |
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
mouse: boolean
spinner: Spinner
}

export const ResolveOptions = Schema.Struct({
Expand Down Expand Up @@ -113,6 +119,7 @@ export function resolve(input: Info, options: ResolveOptions): Resolved {
}),
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
mouse: input.mouse ?? true,
spinner: resolveSpinner(input.spinner),
}
}

Expand Down
16 changes: 16 additions & 0 deletions packages/tui/src/config/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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.
export const SpinnerDefault: Spinner = {
interval: 230,
frames: ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰"],
}

// Permissive on purpose: an unknown name falls back to the default instead of
// invalidating the whole tui.json (host-side load drops the file on schema failure).
export function resolveSpinner(name: string | undefined): Spinner {
if (name !== undefined && Object.hasOwn(spinners, name)) return spinners[name as SpinnerName]
return SpinnerDefault
}
28 changes: 28 additions & 0 deletions packages/tui/test/spinner-config.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test"
import spinners from "cli-spinners"
import { Schema } from "effect"
import { Info, resolve } from "../src/config"
import { resolveSpinner, SpinnerDefault } from "../src/config/spinner"

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(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()
})
Loading