feat(tui): style bg subagent footer (#1288)

This commit is contained in:
Christina Tong
2026-03-05 19:39:11 -07:00
committed by GitHub
parent c0ecdb16d0
commit d60c701f5e
3 changed files with 136 additions and 10 deletions

View File

@@ -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 (
<Text color={color} dimColor={pulse && !blinkOn}>
{output}
</Text>
);
},
);
BlinkingSpinner.displayName = "BlinkingSpinner";

View File

@@ -37,7 +37,7 @@ import {
getSnapshot as getSubagentSnapshot, getSnapshot as getSubagentSnapshot,
subscribe as subscribeToSubagents, subscribe as subscribeToSubagents,
} from "../helpers/subagentState.js"; } from "../helpers/subagentState.js";
import { BlinkDot } from "./BlinkDot.js"; import { BlinkingSpinner } from "./BlinkingSpinner.js";
import { colors } from "./colors"; import { colors } from "./colors";
import { InputAssist } from "./InputAssist"; import { InputAssist } from "./InputAssist";
import { PasteAwareTextInput } from "./PasteAwareTextInput"; import { PasteAwareTextInput } from "./PasteAwareTextInput";
@@ -267,7 +267,23 @@ const InputFooter = memo(function InputFooter({
// Subscribe to subagent state for background agent indicators // Subscribe to subagent state for background agent indicators
useSyncExternalStore(subscribeToSubagents, getSubagentSnapshot); 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) // Tick counter for elapsed time display (only active when background agents exist)
const [, setTick] = useState(0); const [, setTick] = useState(0);
@@ -306,10 +322,10 @@ const InputFooter = memo(function InputFooter({
const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength); const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength);
// When bg agents are active, widen the right column to fit the indicator + label // 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 = const bgIndicatorWidth =
backgroundAgents.length > 0 backgroundAgents.length > 0
? 2 + ? 3 +
bgAgentParts.reduce( bgAgentParts.reduce(
(acc, p, i) => (acc, p, i) =>
acc + acc +
@@ -416,21 +432,30 @@ const InputFooter = memo(function InputFooter({
)) ))
) : backgroundAgents.length > 0 ? ( ) : backgroundAgents.length > 0 ? (
<Text> <Text>
<BlinkDot color={colors.tool.pending} symbol="·" /> <BlinkingSpinner
<Text dimColor> </Text> color={colors.bgSubagent.spinner}
width={2}
marginRight={0}
pulseIntervalMs={400}
/>
{bgAgentParts.map((part, i) => ( {bgAgentParts.map((part, i) => (
<Text key={`bg-agent-${part}`}> <Text key={`bg-agent-${part}`}>
{i > 0 && ( {i > 0 && (
<Text key={`bg-agent-indicator-${part}`} dimColor> <Text
key={`bg-agent-indicator-${part}`}
color={colors.bgSubagent.label}
>
{" · "} {" · "}
</Text> </Text>
)} )}
{part.chatUrl ? ( {part.chatUrl ? (
<Link url={part.chatUrl}> <Link url={part.chatUrl} fallback={false}>
<Text dimColor>{part.typeLabel}</Text> <Text color={colors.bgSubagent.label}>
{part.typeLabel}
</Text>
</Link> </Link>
) : ( ) : (
<Text dimColor>{part.typeLabel}</Text> <Text color={colors.bgSubagent.label}>{part.typeLabel}</Text>
)} )}
<Text dimColor> ({part.elapsed})</Text> <Text dimColor> ({part.elapsed})</Text>
</Text> </Text>

View File

@@ -161,6 +161,12 @@ const _colors = {
hint: "#808080", // Grey to match Ink's dimColor hint: "#808080", // Grey to match Ink's dimColor
}, },
// Background subagent
bgSubagent: {
label: "#87af87",
spinner: "#5faf5f",
},
// Info/modal views // Info/modal views
info: { info: {
border: brandColors.primaryAccent, border: brandColors.primaryAccent,