diff --git a/src/cli/components/BlinkingSpinner.tsx b/src/cli/components/BlinkingSpinner.tsx new file mode 100644 index 0000000..117cc9e --- /dev/null +++ b/src/cli/components/BlinkingSpinner.tsx @@ -0,0 +1,95 @@ +import { memo, useEffect, useState } from "react"; +import stringWidth from "string-width"; +import { useAnimation } from "../contexts/AnimationContext.js"; +import { colors } from "./colors.js"; +import { Text } from "./Text"; + +export const BRAILLE_SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +] as const; + +/** + * Frame-based spinner for lightweight status indicators in the TUI. + */ +export const BlinkingSpinner = memo( + ({ + color = colors.tool.pending, + frames = BRAILLE_SPINNER_FRAMES, + intervalMs = 90, + pulse = true, + pulseIntervalMs = 300, + width = 1, + marginRight = 0, + shouldAnimate: shouldAnimateProp, + }: { + color?: string; + frames?: readonly string[]; + intervalMs?: number; + pulse?: boolean; + pulseIntervalMs?: number; + width?: number; + marginRight?: number; + shouldAnimate?: boolean; + }) => { + const { shouldAnimate: shouldAnimateContext } = useAnimation(); + const shouldAnimate = + shouldAnimateProp === false ? false : shouldAnimateContext; + + const [frameIndex, setFrameIndex] = useState(0); + const [blinkOn, setBlinkOn] = useState(true); + + useEffect(() => { + if (!shouldAnimate || frames.length === 0) return; + + const timer = setInterval(() => { + setFrameIndex((v) => (v + 1) % frames.length); + }, intervalMs); + + return () => clearInterval(timer); + }, [shouldAnimate, frames, intervalMs]); + + useEffect(() => { + if (!shouldAnimate || !pulse) return; + + const timer = setInterval(() => { + setBlinkOn((v) => !v); + }, pulseIntervalMs); + + return () => clearInterval(timer); + }, [shouldAnimate, pulse, pulseIntervalMs]); + + const frame = + frames.length > 0 + ? shouldAnimate + ? (frames[frameIndex] ?? frames[0] ?? "·") + : (frames[0] ?? "·") + : "·"; + + const frameWidth = stringWidth(frame); + const targetWidth = Math.max(1, width); + const totalPadding = Math.max(0, targetWidth - frameWidth); + const leftPadding = Math.floor(totalPadding / 2); + const rightPadding = totalPadding - leftPadding; + const paddedFrame = + " ".repeat(leftPadding) + frame + " ".repeat(rightPadding); + + const output = paddedFrame + " ".repeat(Math.max(0, marginRight)); + + return ( + + {output} + + ); + }, +); + +BlinkingSpinner.displayName = "BlinkingSpinner"; diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 393cac5..b2f1ca6 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -37,7 +37,7 @@ import { getSnapshot as getSubagentSnapshot, subscribe as subscribeToSubagents, } from "../helpers/subagentState.js"; -import { BlinkDot } from "./BlinkDot.js"; +import { BlinkingSpinner } from "./BlinkingSpinner.js"; import { colors } from "./colors"; import { InputAssist } from "./InputAssist"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; @@ -267,7 +267,23 @@ const InputFooter = memo(function InputFooter({ // Subscribe to subagent state for background agent indicators useSyncExternalStore(subscribeToSubagents, getSubagentSnapshot); - const backgroundAgents = [...getActiveBackgroundAgents()]; + const backgroundAgents = [ + ...getActiveBackgroundAgents(), + // DEBUG: hardcoded agent for local footer testing + { + id: "debug-bg-agent", + type: "Reflection", + description: "Debug background agent", + status: "running" as const, + agentURL: "https://app.letta.com/chat/agent-debug-link", + toolCalls: [], + totalTokens: 0, + durationMs: 0, + startTime: Date.now() - 12_000, + isBackground: true, + silent: true, + }, + ]; // Tick counter for elapsed time display (only active when background agents exist) const [, setTick] = useState(0); @@ -306,10 +322,10 @@ const InputFooter = memo(function InputFooter({ const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength); // When bg agents are active, widen the right column to fit the indicator + label - // "· " (2) + parts text + " │ " (3) + // spinner slot (3) + parts text + " │ " (3) const bgIndicatorWidth = backgroundAgents.length > 0 - ? 2 + + ? 3 + bgAgentParts.reduce( (acc, p, i) => acc + @@ -416,21 +432,30 @@ const InputFooter = memo(function InputFooter({ )) ) : backgroundAgents.length > 0 ? ( - - + {bgAgentParts.map((part, i) => ( {i > 0 && ( - + {" · "} )} {part.chatUrl ? ( - - {part.typeLabel} + + + {part.typeLabel} + ) : ( - {part.typeLabel} + {part.typeLabel} )} ({part.elapsed}) diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index f04a277..f616cdc 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -161,6 +161,12 @@ const _colors = { hint: "#808080", // Grey to match Ink's dimColor }, + // Background subagent + bgSubagent: { + label: "#87af87", + spinner: "#5faf5f", + }, + // Info/modal views info: { border: brandColors.primaryAccent,