summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorSebastian Herrlinger <[email protected]>2025-11-24 19:39:09 +0100
committerSebastian Herrlinger <[email protected]>2025-11-24 19:39:09 +0100
commit82ebf66cbada3513fdfee7af784dfcf120462fec (patch)
tree222df7a81f4822458cfa61e2bc51aaaf8ee469b6 /packages
parent883ed4d424082a8b132d6c7ba985161bb0d7e7a7 (diff)
downloadopencode-82ebf66cbada3513fdfee7af784dfcf120462fec.tar.gz
opencode-82ebf66cbada3513fdfee7af784dfcf120462fec.zip
non-corpo loading spinner
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/package.json1
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx59
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx56
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/spinner.ts405
4 files changed, 429 insertions, 92 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 25ee3983b..3b6e07604 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -82,6 +82,7 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
+ "opentui-spinner": "0.0.5",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index f2e97ff23..4e2fa9d35 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -1,5 +1,6 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
+import "opentui-spinner/solid";
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
@@ -20,7 +21,7 @@ import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
-import { Shimmer } from "../../ui/shimmer"
+import { createColors, createFrames } from "../../ui/spinner.ts"
export type PromptProps = {
sessionID?: string
@@ -545,6 +546,22 @@ export function Prompt(props: PromptProps) {
return local.agent.color(local.agent.current().name)
})
+ const spinnerDef = createMemo(() => {
+ const color = local.agent.color(local.agent.current().name)
+ return {
+ frames: createFrames({
+ color,
+ style: "blocks",
+ inactiveFactor: 0.25,
+ }),
+ color: createColors({
+ color,
+ style: "blocks",
+ inactiveFactor: 0.25,
+ }),
+ }
+ })
+
createEffect(() => {
renderer.setCursorColor(highlight())
})
@@ -813,7 +830,11 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
- <Loader />
+ <spinner
+ color={spinnerDef().color}
+ frames={spinnerDef().frames}
+ interval={40}
+ />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@@ -884,37 +905,3 @@ export function Prompt(props: PromptProps) {
)
}
-function Loader() {
- const FRAMES = [
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- "▰▱▱▱▱▱▱",
- "▰▰▱▱▱▱▱",
- "▰▰▰▱▱▱▱",
- "▱▰▰▰▱▱▱",
- "▱▱▰▰▰▱▱",
- "▱▱▱▰▰▰▱",
- "▱▱▱▱▰▰▰",
- "▱▱▱▱▱▰▰",
- "▱▱▱▱▱▱▰",
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- "▱▱▱▱▱▱▱",
- ]
- const [frame, setFrame] = createSignal(0)
-
- onMount(() => {
- const timer = setInterval(() => {
- setFrame((frame() + 1) % FRAMES.length)
- }, 100)
- onCleanup(() => {
- clearInterval(timer)
- })
- })
-
- const { theme } = useTheme()
- return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
-}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
deleted file mode 100644
index 6c5629b8a..000000000
--- a/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { RGBA } from "@opentui/core"
-import { useTimeline } from "@opentui/solid"
-import { createMemo, createSignal } from "solid-js"
-
-export type ShimmerProps = {
- text: string
- color: RGBA
-}
-
-const DURATION = 2_500
-
-export function Shimmer(props: ShimmerProps) {
- const timeline = useTimeline({
- duration: DURATION,
- loop: true,
- })
- const characters = props.text.split("")
- const color = props.color
-
- const shimmerSignals = characters.map((_, i) => {
- const [shimmer, setShimmer] = createSignal(0.4)
- const target = {
- shimmer: shimmer(),
- setShimmer,
- }
-
- timeline!.add(
- target,
- {
- shimmer: 1,
- duration: DURATION / (props.text.length + 1),
- ease: "linear",
- alternate: true,
- loop: 2,
- onUpdate: () => {
- target.setShimmer(target.shimmer)
- },
- },
- (i * (DURATION / (props.text.length + 1))) / 2,
- )
-
- return shimmer
- })
-
- return (
- <text>
- {(() => {
- return characters.map((ch, i) => {
- const shimmer = shimmerSignals[i]
- const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
- return <span style={{ fg }}>{ch}</span>
- })
- })()}
- </text>
- )
-}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/spinner.ts b/packages/opencode/src/cli/cmd/tui/ui/spinner.ts
new file mode 100644
index 000000000..e09b079b4
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/spinner.ts
@@ -0,0 +1,405 @@
+import type { ColorInput } from "@opentui/core";
+import { RGBA } from "@opentui/core";
+import type { ColorGenerator } from "opentui-spinner";
+
+interface AdvancedGradientOptions {
+ colors: ColorInput[];
+ trailLength: number;
+ defaultColor?: ColorInput;
+ direction?: "forward" | "backward" | "bidirectional";
+ holdFrames?: { start?: number; end?: number };
+}
+
+interface ScannerState {
+ activePosition: number;
+ isHolding: boolean;
+ holdProgress: number;
+ holdTotal: number;
+ movementProgress: number;
+ movementTotal: number;
+ isMovingForward: boolean;
+}
+
+function getScannerState(
+ frameIndex: number,
+ totalChars: number,
+ options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
+): ScannerState {
+ const { direction = "forward", holdFrames = {} } = options;
+
+ if (direction === "bidirectional") {
+ const forwardFrames = totalChars;
+ const holdEndFrames = holdFrames.end ?? 0;
+ const backwardFrames = totalChars - 1;
+
+ if (frameIndex < forwardFrames) {
+ // Moving forward
+ return {
+ activePosition: frameIndex,
+ isHolding: false,
+ holdProgress: 0,
+ holdTotal: 0,
+ movementProgress: frameIndex,
+ movementTotal: forwardFrames,
+ isMovingForward: true,
+ };
+ } else if (frameIndex < forwardFrames + holdEndFrames) {
+ // Holding at end
+ return {
+ activePosition: totalChars - 1,
+ isHolding: true,
+ holdProgress: frameIndex - forwardFrames,
+ holdTotal: holdEndFrames,
+ movementProgress: 0,
+ movementTotal: 0,
+ isMovingForward: true,
+ };
+ } else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
+ // Moving backward
+ const backwardIndex = frameIndex - forwardFrames - holdEndFrames;
+ return {
+ activePosition: totalChars - 2 - backwardIndex,
+ isHolding: false,
+ holdProgress: 0,
+ holdTotal: 0,
+ movementProgress: backwardIndex,
+ movementTotal: backwardFrames,
+ isMovingForward: false,
+ };
+ } else {
+ // Holding at start
+ return {
+ activePosition: 0,
+ isHolding: true,
+ holdProgress:
+ frameIndex - forwardFrames - holdEndFrames - backwardFrames,
+ holdTotal: holdFrames.start ?? 0,
+ movementProgress: 0,
+ movementTotal: 0,
+ isMovingForward: false,
+ };
+ }
+ } else if (direction === "backward") {
+ return {
+ activePosition: totalChars - 1 - (frameIndex % totalChars),
+ isHolding: false,
+ holdProgress: 0,
+ holdTotal: 0,
+ movementProgress: frameIndex % totalChars,
+ movementTotal: totalChars,
+ isMovingForward: false,
+ };
+ } else {
+ return {
+ activePosition: frameIndex % totalChars,
+ isHolding: false,
+ holdProgress: 0,
+ holdTotal: 0,
+ movementProgress: frameIndex % totalChars,
+ movementTotal: totalChars,
+ isMovingForward: true,
+ };
+ }
+}
+
+function calculateColorIndex(
+ frameIndex: number,
+ charIndex: number,
+ totalChars: number,
+ options: Pick<
+ AdvancedGradientOptions,
+ "direction" | "holdFrames" | "trailLength"
+ >,
+ state?: ScannerState,
+): number {
+ const { trailLength } = options;
+ const { activePosition, isHolding, holdProgress, isMovingForward } =
+ state ?? getScannerState(frameIndex, totalChars, options);
+
+ // Calculate directional distance (positive means trailing behind)
+ const directionalDistance = isMovingForward
+ ? activePosition - charIndex // For forward: trail is to the left (lower indices)
+ : charIndex - activePosition; // For backward: trail is to the right (higher indices)
+
+ // Handle hold frame fading: keep the lead bright, fade the trail
+ if (isHolding) {
+ // Shift the color index by how long we've been holding
+ return directionalDistance + holdProgress;
+ }
+
+ // Normal movement - show gradient trail only behind the movement direction
+ if (directionalDistance > 0 && directionalDistance < trailLength) {
+ return directionalDistance;
+ }
+
+ // At the active position, show the brightest color
+ if (directionalDistance === 0) {
+ return 0;
+ }
+
+ return -1;
+}
+
+function createKnightRiderTrail(
+ options: AdvancedGradientOptions,
+): ColorGenerator {
+ const { colors, defaultColor } = options;
+
+ // Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
+ // We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
+ // Note: If defaultColor is a string, we convert it once here.
+ const defaultRgba =
+ defaultColor instanceof RGBA
+ ? defaultColor
+ : RGBA.fromHex((defaultColor as string) || "#000000");
+
+ let cachedFrameIndex = -1;
+ let cachedState: ScannerState | null = null;
+
+ return (
+ frameIndex: number,
+ charIndex: number,
+ _totalFrames: number,
+ totalChars: number,
+ ) => {
+ if (frameIndex !== cachedFrameIndex) {
+ cachedFrameIndex = frameIndex;
+ cachedState = getScannerState(frameIndex, totalChars, options);
+ }
+
+ const state = cachedState!;
+
+ const index = calculateColorIndex(
+ frameIndex,
+ charIndex,
+ totalChars,
+ options,
+ state,
+ );
+
+ // Calculate global fade for inactive dots during hold or movement
+ const {
+ isHolding,
+ holdProgress,
+ holdTotal,
+ movementProgress,
+ movementTotal,
+ } = state;
+
+ let alpha = 1.0;
+ if (isHolding && holdTotal > 0) {
+ // Fade out linearly
+ const progress = Math.min(holdProgress / holdTotal, 1);
+ alpha = Math.max(0, 1 - progress);
+ } else if (!isHolding && movementTotal > 0) {
+ // Fade in linearly during movement
+ const progress = Math.min(
+ movementProgress / Math.max(1, movementTotal - 1),
+ 1,
+ );
+ alpha = progress;
+ }
+
+ // Mutate the alpha of the default RGBA object
+ // This assumes single-threaded, synchronous rendering per frame
+ // where we can modify the state for the current frame.
+ // Since this is run for every char in the frame, setting it repeatedly to the same value is fine.
+ defaultRgba.a = alpha;
+
+ if (index === -1) {
+ return defaultRgba;
+ }
+
+ return colors[index] ?? defaultRgba;
+ };
+}
+
+/**
+ * Derives a gradient of tail colors from a single bright color
+ * @param brightColor The brightest color (center/head of the scanner)
+ * @param steps Number of gradient steps (default: 6)
+ * @returns Array of RGBA colors from brightest to darkest
+ */
+export function deriveTrailColors(
+ brightColor: ColorInput,
+ steps: number = 6,
+): RGBA[] {
+ const baseRgba =
+ brightColor instanceof RGBA
+ ? brightColor
+ : RGBA.fromHex(brightColor as string);
+
+ const colors: RGBA[] = [];
+
+ for (let i = 0; i < steps; i++) {
+ // Progressive darkening:
+ // i=0: 100% brightness (original color)
+ // i=1: add slight bloom/glare (lighten)
+ // i=2+: progressively darken
+ let factor: number;
+
+ if (i === 0) {
+ factor = 1.0; // Original brightness
+ } else if (i === 1) {
+ factor = 1.2; // Slight bloom/glare effect
+ } else {
+ // Exponential decay for natural-looking trail fade
+ factor = Math.pow(0.6, i - 1);
+ }
+
+ const r = Math.min(1.0, baseRgba.r * factor);
+ const g = Math.min(1.0, baseRgba.g * factor);
+ const b = Math.min(1.0, baseRgba.b * factor);
+
+ colors.push(RGBA.fromValues(r, g, b, 1.0));
+ }
+
+ return colors;
+}
+
+/**
+ * Derives the inactive/default color from a bright color
+ * @param brightColor The brightest color (center/head of the scanner)
+ * @param factor Brightness factor for inactive color (default: 0.2)
+ * @returns A much darker version suitable for inactive dots
+ */
+export function deriveInactiveColor(
+ brightColor: ColorInput,
+ factor: number = 0.2,
+): RGBA {
+ const baseRgba =
+ brightColor instanceof RGBA
+ ? brightColor
+ : RGBA.fromHex(brightColor as string);
+
+ const r = baseRgba.r * factor;
+ const g = baseRgba.g * factor;
+ const b = baseRgba.b * factor;
+
+ return RGBA.fromValues(r, g, b, 1.0);
+}
+
+export type KnightRiderStyle = "blocks" | "diamonds";
+
+export interface KnightRiderOptions {
+ width?: number;
+ style?: KnightRiderStyle;
+ holdStart?: number;
+ holdEnd?: number;
+ colors?: ColorInput[];
+ /** Single color to derive trail from (alternative to providing colors array) */
+ color?: ColorInput;
+ /** Number of trail steps when using single color (default: 6) */
+ trailSteps?: number;
+ defaultColor?: ColorInput;
+ /** Brightness factor for inactive color when using single color (default: 0.2) */
+ inactiveFactor?: number;
+}
+
+/**
+ * Creates frame strings for a Knight Rider style scanner animation
+ * @param options Configuration options for the Knight Rider effect
+ * @returns Array of frame strings
+ */
+export function createFrames(options: KnightRiderOptions = {}): string[] {
+ const width = options.width ?? 8;
+ const style = options.style ?? "diamonds";
+ const holdStart = options.holdStart ?? 30;
+ const holdEnd = options.holdEnd ?? 9;
+
+ const colors =
+ options.colors ??
+ (options.color
+ ? deriveTrailColors(options.color, options.trailSteps)
+ : [
+ RGBA.fromHex("#ff0000"), // Brightest Red (Center)
+ RGBA.fromHex("#ff5555"), // Glare/Bloom
+ RGBA.fromHex("#dd0000"), // Trail 1
+ RGBA.fromHex("#aa0000"), // Trail 2
+ RGBA.fromHex("#770000"), // Trail 3
+ RGBA.fromHex("#440000"), // Trail 4
+ ]);
+
+ const defaultColor =
+ options.defaultColor ??
+ (options.color
+ ? deriveInactiveColor(options.color, options.inactiveFactor)
+ : RGBA.fromHex("#330000"));
+
+ const trailOptions = {
+ colors,
+ trailLength: colors.length,
+ defaultColor,
+ direction: "bidirectional" as const,
+ holdFrames: { start: holdStart, end: holdEnd },
+ };
+
+ // Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
+ const totalFrames = width + holdEnd + (width - 1) + holdStart;
+
+ // Generate dynamic frames where inactive pixels are dots and active ones are blocks
+ const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
+ return Array.from({ length: width }, (_, charIndex) => {
+ const index = calculateColorIndex(
+ frameIndex,
+ charIndex,
+ width,
+ trailOptions,
+ );
+
+ if (style === "diamonds") {
+ const shapes = ["⬥", "◆", "⬩", "⬪"];
+ if (index >= 0 && index < trailOptions.colors.length) {
+ return shapes[Math.min(index, shapes.length - 1)];
+ }
+ return "·";
+ }
+
+ // Default to blocks
+ // It's active if we have a valid color index that is within our colors array
+ const isActive = index >= 0 && index < trailOptions.colors.length;
+ return isActive ? "■" : "⬝";
+ }).join("");
+ });
+
+ return frames;
+}
+
+/**
+ * Creates a color generator function for Knight Rider style scanner animation
+ * @param options Configuration options for the Knight Rider effect
+ * @returns ColorGenerator function
+ */
+export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
+ const holdStart = options.holdStart ?? 30;
+ const holdEnd = options.holdEnd ?? 9;
+
+ const colors =
+ options.colors ??
+ (options.color
+ ? deriveTrailColors(options.color, options.trailSteps)
+ : [
+ RGBA.fromHex("#ff0000"), // Brightest Red (Center)
+ RGBA.fromHex("#ff5555"), // Glare/Bloom
+ RGBA.fromHex("#dd0000"), // Trail 1
+ RGBA.fromHex("#aa0000"), // Trail 2
+ RGBA.fromHex("#770000"), // Trail 3
+ RGBA.fromHex("#440000"), // Trail 4
+ ]);
+
+ const defaultColor =
+ options.defaultColor ??
+ (options.color
+ ? deriveInactiveColor(options.color, options.inactiveFactor)
+ : RGBA.fromHex("#330000"));
+
+ const trailOptions = {
+ colors,
+ trailLength: colors.length,
+ defaultColor,
+ direction: "bidirectional" as const,
+ holdFrames: { start: holdStart, end: holdEnd },
+ };
+
+ return createKnightRiderTrail(trailOptions);
+}