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,
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 ? (
<Text>
<BlinkDot color={colors.tool.pending} symbol="·" />
<Text dimColor> </Text>
<BlinkingSpinner
color={colors.bgSubagent.spinner}
width={2}
marginRight={0}
pulseIntervalMs={400}
/>
{bgAgentParts.map((part, i) => (
<Text key={`bg-agent-${part}`}>
{i > 0 && (
<Text key={`bg-agent-indicator-${part}`} dimColor>
<Text
key={`bg-agent-indicator-${part}`}
color={colors.bgSubagent.label}
>
{" · "}
</Text>
)}
{part.chatUrl ? (
<Link url={part.chatUrl}>
<Text dimColor>{part.typeLabel}</Text>
<Link url={part.chatUrl} fallback={false}>
<Text color={colors.bgSubagent.label}>
{part.typeLabel}
</Text>
</Link>
) : (
<Text dimColor>{part.typeLabel}</Text>
<Text color={colors.bgSubagent.label}>{part.typeLabel}</Text>
)}
<Text dimColor> ({part.elapsed})</Text>
</Text>

View File

@@ -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,