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,