feat(tui): style bg subagent footer (#1288)
This commit is contained in:
95
src/cli/components/BlinkingSpinner.tsx
Normal file
95
src/cli/components/BlinkingSpinner.tsx
Normal 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";
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user