Compare commits

...

12 Commits

Author SHA1 Message Date
openhands
9392c5a60e fix: Add missing test id to unified spinner in UserAvatar
The spinner deduplication replaced the old LoadingSpinner with the unified
Spinner component, but the test was still looking for data-testid='loading-spinner'.
Added the missing data-testid prop to maintain test compatibility.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-25 14:26:36 +00:00
Engel Nyst
9b292694ed Update .openhands/TASKS.md 2025-08-23 01:27:29 +02:00
Xingyao Wang
c4400d97a2 Merge branch 'main' into openhands/fix-issue-10533 2025-08-22 15:33:39 -04:00
openhands
409ec72088 Deduplicate CLI spinner implementations
- Created unified Spinner class in openhands/cli/spinner.py
- Removed duplicate show_loading_spinner from commands.py
- Removed duplicate display_initialization_animation from tui.py
- Both functions now use the unified Spinner class
- Maintains backward compatibility with existing function signatures
- Supports both threading.Event and asyncio.Event patterns
- Preserves ANSI color formatting for initialization animation

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-22 16:51:58 +00:00
openhands
c522afd568 Fix spinner import paths to use consistent absolute imports
- Updated relative imports to use absolute path #/components/shared/spinner
- Ensures consistent import style across all spinner usages

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-22 16:25:44 +00:00
openhands
c70133295a Deduplicate spinner implementations
- Created unified Spinner component at frontend/src/components/shared/spinner.tsx
- Replaced all LoadingSpinner usages (15 files) with unified Spinner
- Replaced all HeroUI Spinner usages (7 files) with unified Spinner
- Removed duplicate LoadingSpinner component
- Updated size props from HeroUI format (sm/lg) to unified format (small/medium/large)
- All spinner implementations now use consistent styling and behavior

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-22 16:24:07 +00:00
openhands
bcd5983583 cli: rename /conv command to /conversations across CLI and tests\n\n- Update help text in openhands/cli/tui.py\n- Update handler docstring and keep handler name for compatibility\n- Update unit test to use /conversations\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-21 19:18:45 +00:00
Xingyao Wang
e21837a205 Update openhands/cli/commands.py
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-08-22 03:05:24 +08:00
openhands
7ca3fb4e1a Improve /conv command UI and loading experience
- Remove 'Conversation History' header text
- Change 'Recent Conversations' text to white (remove blue color)
- Add animated loading spinner while loading conversations
- Use braille spinner characters for smooth animation
- Clear spinner properly after loading completes

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 20:02:42 +00:00
openhands
71185b2773 Replace newlines with ↵ symbol in conversation messages
- Update truncate_message() to replace \n and \r with ↵ symbol
- Apply same treatment to conversation details view
- Add test for newline replacement functionality
- Improves UI readability by keeping messages on single lines

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 19:46:48 +00:00
openhands
827c21f865 Optimize /conv command performance and UX
- Replace slow FileConversationStore.search() with direct filesystem access
- List conversation folders by creation time for better performance
- Show first 10 conversations immediately when /conv is typed (no menu)
- Add efficient pagination with lazy loading
- Improve user experience with immediate display and navigation options
- Update tests to match new implementation
- All 42 tests passing

Fixes performance issues when there are many conversation sessions
and improves user experience by removing unnecessary menu step.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 19:25:04 +00:00
openhands
8c5631ba48 Add CLI command /conv to list conversation history and show initial user prompts
- Implement /conv command with interactive menu system
- Add list_conversations function with pagination support (10 conversations per page)
- Add view_conversation_details function to show all user messages from a conversation
- Add get_initial_user_message helper to extract first user message from conversation
- Add get_user_messages_from_conversation helper to extract all user messages
- Add truncate_message helper to limit message display to 100 characters
- Add comprehensive test coverage for all new functionality
- Update tui.py to include /conv in help command list

Fixes #10533

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 18:44:35 +00:00
26 changed files with 673 additions and 99 deletions

15
.openhands/TASKS.md Normal file
View File

@@ -0,0 +1,15 @@
# Task List
1. ✅ Analyze all spinner implementations and their usage
2. ✅ Create a unified spinner component to replace all duplicates
3. ✅ Replace LoadingSpinner usages with unified spinner
4. ✅ Replace local LoadingSpinner in diff-viewer with unified spinner
5. ✅ Replace HeroUI Spinner usages with unified spinner
6. ✅ Remove duplicate spinner components
7. 🔄 Test that all spinner replacements work correctly

View File

@@ -22,7 +22,7 @@ import { ActionSuggestions } from "./action-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
@@ -208,7 +208,7 @@ export function ChatInterface() {
>
{isLoadingMessages && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
<Spinner size="small" />
</div>
)}

View File

@@ -1,5 +1,5 @@
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { Spinner } from "#/components/shared/spinner";
import { ModalBody } from "#/components/shared/modals/modal-body";
export function LoadingMicroagentBody() {
@@ -9,7 +9,7 @@ export function LoadingMicroagentBody() {
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
</h2>
<Spinner size="lg" />
<Spinner size="large" />
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
</ModalBody>
);

View File

@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { Spinner } from "#/components/shared/spinner";
import { MicroagentStatus } from "#/types/microagent-status";
import { SuccessIndicator } from "../success-indicator";
@@ -36,7 +36,7 @@ export function MicroagentStatusIndicator({
const getStatusIcon = () => {
switch (status) {
case MicroagentStatus.CREATING:
return <Spinner size="sm" />;
return <Spinner size="small" />;
case MicroagentStatus.COMPLETED:
return <SuccessIndicator status="success" />;
case MicroagentStatus.ERROR:

View File

@@ -1,6 +1,6 @@
import toast from "react-hot-toast";
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { Spinner } from "#/components/shared/spinner";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import CloseIcon from "#/icons/close.svg?react";
import { SuccessIndicator } from "../success-indicator";
@@ -17,7 +17,7 @@ function ConversationCreatedToast({
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<Spinner size="small" />
<div>
{t("MICROAGENT$ADDING_CONTEXT")}
<br />

View File

@@ -9,7 +9,7 @@ import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
@@ -132,7 +132,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
>
{isFetching && conversations.length === 0 && (
<div className="w-full h-full absolute flex justify-center items-center">
<LoadingSpinner size="small" />
<Spinner size="small" />
</div>
)}
{error && (
@@ -183,7 +183,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
{/* Loading indicator for fetching more conversations */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<LoadingSpinner size="small" />
<Spinner size="small" />
</div>
)}

View File

@@ -8,26 +8,7 @@ import { getLanguageFromPath } from "#/utils/get-language-from-path";
import { cn } from "#/utils/utils";
import ChevronUp from "#/icons/chveron-up.svg?react";
import { useGitDiff } from "#/hooks/query/use-get-diff";
interface LoadingSpinnerProps {
className?: string;
}
// TODO: Move out of this file and replace the current spinner with this one
function LoadingSpinner({ className }: LoadingSpinnerProps) {
return (
<div className="flex items-center justify-center">
<div
className={cn(
"animate-spin rounded-full border-4 border-gray-200 border-t-blue-500",
className,
)}
role="status"
aria-label="Loading"
/>
</div>
);
}
import { Spinner } from "#/components/shared/spinner";
const STATUS_MAP: Record<GitChangeStatus, string | IconType> = {
A: LuFilePlus,
@@ -144,7 +125,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
onClick={() => setIsCollapsed((prev) => !prev)}
>
<span className="text-sm w-full text-content flex items-center gap-2">
{isFetchingData && <LoadingSpinner className="w-5 h-5" />}
{isFetchingData && <Spinner size="small" />}
{!isFetchingData && statusIcon}
<strong className="w-full truncate">{filePath}</strong>
<button data-testid="collapse" type="button">

View File

@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { Spinner } from "#/components/shared/spinner";
import { cn } from "#/utils/utils";
interface BranchLoadingStateProps {
@@ -18,7 +18,7 @@ export function BranchLoadingState({
wrapperClassName,
)}
>
<Spinner size="sm" />
<Spinner size="small" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Spinner } from "@heroui/react";
import { Spinner } from "#/components/shared/spinner";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
@@ -86,7 +86,7 @@ export function MicroagentManagementRepoMicroagents({
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<Spinner size="sm" data-testid="loading-spinner" />
<Spinner size="small" data-testid="loading-spinner" />
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { Spinner } from "#/components/shared/spinner";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
@@ -65,7 +65,7 @@ export function MicroagentManagementSidebar({
<MicroagentManagementSidebarHeader />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<Spinner size="sm" />
<Spinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>

View File

@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { Spinner } from "#/components/shared/spinner";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
@@ -46,7 +46,10 @@ export function MicroagentManagementViewMicroagentContent() {
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
{isLoading && (
<div className="flex items-center justify-center w-full h-full">
<Spinner size="lg" data-testid="loading-microagent-content-spinner" />
<Spinner
size="large"
data-testid="loading-microagent-content-spinner"
/>
</div>
)}
{error && (

View File

@@ -6,7 +6,7 @@ import { cn } from "#/utils/utils";
import MoneyIcon from "#/icons/money.svg?react";
import { SettingsInput } from "../settings/settings-input";
import { BrandButton } from "../settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
import { I18nKey } from "#/i18n/declaration";
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
@@ -54,7 +54,7 @@ export function PaymentForm() {
{!isLoading && (
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
)}
{isLoading && <LoadingSpinner size="small" />}
{isLoading && <Spinner size="small" />}
</div>
<div className="flex flex-col gap-3">
@@ -79,7 +79,7 @@ export function PaymentForm() {
>
{t(I18nKey.PAYMENT$ADD_CREDIT)}
</BrandButton>
{isPending && <LoadingSpinner size="small" />}
{isPending && <Spinner size="small" />}
<PoweredByStripeTag />
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { useTranslation, Trans } from "react-i18next";
import { FaTrash, FaEye, FaEyeSlash, FaCopy } from "react-icons/fa6";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
import {
displayErrorToast,
@@ -64,7 +64,7 @@ function LlmApiKeyManager({
isDisabled={refreshLlmApiKey.isPending}
>
{refreshLlmApiKey.isPending ? (
<LoadingSpinner size="small" />
<Spinner size="small" />
) : (
t(I18nKey.SETTINGS$REFRESH_LLM_API_KEY)
)}
@@ -148,7 +148,7 @@ function ApiKeysTable({ apiKeys, isLoading, onDeleteKey }: ApiKeysTableProps) {
if (isLoading) {
return (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
<Spinner size="large" />
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { CreateApiKeyResponse } from "#/api/api-keys";
import {
displayErrorToast,
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
isDisabled={createApiKeyMutation.isPending || !newKeyName.trim()}
>
{createApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
<Spinner size="small" />
) : (
t(I18nKey.BUTTON$CREATE)
)}

View File

@@ -2,7 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { ApiKey } from "#/api/api-keys";
import {
displayErrorToast,
@@ -49,7 +49,7 @@ export function DeleteApiKeyModal({
isDisabled={deleteApiKeyMutation.isPending}
>
{deleteApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
<Spinner size="small" />
) : (
t(I18nKey.BUTTON$DELETE)
)}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import ProfileIcon from "#/icons/profile.svg?react";
import { cn } from "#/utils/utils";
import { Avatar } from "./avatar";
@@ -35,7 +35,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
className="text-[#9099AC]"
/>
)}
{isLoading && <LoadingSpinner size="small" />}
{isLoading && <Spinner size="small" data-testid="loading-spinner" />}
</TooltipButton>
);
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
import { BetaBadge } from "./beta-badge";
import { LoadingSpinner } from "../shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
interface NavTabProps {
to: string;
@@ -40,7 +40,7 @@ export function NavTab({
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{rightContent}
{isLoading && <LoadingSpinner size="small" />}
{isLoading && <Spinner size="small" />}
</div>
</div>
)}

View File

@@ -1,6 +1,6 @@
import React, { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { LoadingSpinner } from "../shared/loading-spinner";
import { Spinner } from "#/components/shared/spinner";
// Lazy load all tab components
const EditorTab = lazy(() => import("#/routes/changes-tab"));
@@ -32,7 +32,7 @@ export function TabContent({ conversationPath }: TabContentProps) {
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<LoadingSpinner size="large" />
<Spinner size="large" />
</div>
}
>

View File

@@ -1,23 +0,0 @@
import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
import { cn } from "#/utils/utils";
interface LoadingSpinnerProps {
size: "small" | "large";
}
export function LoadingSpinner({ size }: LoadingSpinnerProps) {
const sizeStyle =
size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
return (
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
<div
className={cn(
"rounded-full border-4 border-[#525252] absolute",
sizeStyle,
)}
/>
<LoadingSpinnerOuter className={cn("absolute animate-spin", sizeStyle)} />
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { I18nKey } from "#/i18n/declaration";
import { LoadingSpinner } from "../../loading-spinner";
import { Spinner } from "#/components/shared/spinner";
import { ModalBackdrop } from "../modal-backdrop";
import { SettingsForm } from "./settings-form";
import { Settings } from "#/types/settings";
@@ -43,7 +43,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
{aiConfigOptions.isLoading && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
<Spinner size="small" />
</div>
)}
{aiConfigOptions.data && (

View File

@@ -0,0 +1,32 @@
import { cn } from "#/utils/utils";
interface SpinnerProps {
size?: "small" | "medium" | "large";
className?: string;
"data-testid"?: string;
}
const SIZE_CLASSES = {
small: "w-5 h-5",
medium: "w-8 h-8",
large: "w-12 h-12",
};
export function Spinner({
size = "medium",
className,
"data-testid": testId = "spinner",
}: SpinnerProps) {
return (
<div
data-testid={testId}
className={cn(
"inline-block animate-spin rounded-full border-4 border-gray-200 border-t-blue-500",
SIZE_CLASSES[size],
className,
)}
role="status"
aria-label="Loading"
/>
);
}

View File

@@ -1,6 +1,8 @@
import asyncio
import os
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -17,6 +19,7 @@ from openhands.cli.settings import (
modify_llm_settings_basic,
modify_search_api_settings,
)
from openhands.cli.spinner import show_loading_spinner
from openhands.cli.tui import (
COLOR_GREY,
UsageMetrics,
@@ -50,6 +53,9 @@ from openhands.events.action import (
MessageAction,
)
from openhands.events.stream import EventStream
from openhands.storage import get_file_store
from openhands.storage.conversation.file_conversation_store import FileConversationStore
from openhands.storage.local import LocalFileStore
from openhands.storage.settings.file_settings_store import FileSettingsStore
@@ -165,6 +171,8 @@ async def handle_commands(
)
elif command == '/mcp':
await handle_mcp_command(config)
elif command == '/conversations':
await handle_conv_command(config)
else:
close_repl = True
action = MessageAction(content=command)
@@ -882,3 +890,291 @@ async def remove_mcp_server(config: OpenHandsConfig) -> None:
restart_cli()
else:
print_formatted_text(f'Failed to remove {server_type} server "{identifier}".')
async def handle_conv_command(config: OpenHandsConfig) -> None:
"""Handle the /conversations command to view conversation history."""
# Show loading animation while getting conversations
stop_event = threading.Event()
spinner_thread = threading.Thread(target=show_loading_spinner, args=(stop_event,))
spinner_thread.start()
try:
# Get conversation folders sorted by creation time
conversation_folders = get_conversation_folders_by_time(config)
finally:
# Stop the spinner
stop_event.set()
spinner_thread.join()
if not conversation_folders:
print_formatted_text(HTML('<ansired>No conversations found.</ansired>'))
return
# Start with first page
current_page = 0
page_size = 10
while True:
# Display current page of conversations
start_idx = current_page * page_size
end_idx = start_idx + page_size
page_conversations = conversation_folders[start_idx:end_idx]
if not page_conversations:
print_formatted_text(HTML('<ansired>No more conversations.</ansired>'))
current_page = max(0, current_page - 1)
continue
# Display conversations for current page
await display_conversation_page(config, page_conversations, current_page + 1)
# Show navigation options
options = []
if current_page > 0:
options.append('Previous page')
if end_idx < len(conversation_folders):
options.append('Next page')
options.extend(
[
'View conversation details',
'Go back',
]
)
action = cli_confirm(
config,
f'Conversation History (Page {current_page + 1})',
options,
)
if 'Next page' in options and action == options.index('Next page'):
current_page += 1
elif 'Previous page' in options and action == options.index('Previous page'):
current_page -= 1
elif action == options.index('View conversation details'):
await view_conversation_details(config)
else: # Go back
break
def get_conversation_folders_by_time(
config: OpenHandsConfig,
) -> list[tuple[str, float]]:
"""Get conversation folders sorted by creation time (most recent first)."""
try:
# Get the sessions directory path
file_store = get_file_store(config.file_store, config.file_store_path)
# Only works with LocalFileStore that has a root attribute
if not isinstance(file_store, LocalFileStore):
return []
sessions_path = Path(file_store.root) / 'sessions'
if not sessions_path.exists():
return []
# Get all conversation folders with their creation times
conversation_folders = []
for folder in sessions_path.iterdir():
if folder.is_dir():
try:
# Use folder creation time (or modification time as fallback)
creation_time = folder.stat().st_ctime
conversation_folders.append((folder.name, creation_time))
except OSError:
continue
# Sort by creation time (most recent first)
conversation_folders.sort(key=lambda x: x[1], reverse=True)
return conversation_folders
except Exception:
return []
async def display_conversation_page(
config: OpenHandsConfig, conversations: list[tuple[str, float]], page_num: int
) -> None:
"""Display a page of conversations with their details."""
print_formatted_text(f'Recent Conversations (Page {page_num}):')
print()
for i, (conversation_id, creation_time) in enumerate(conversations, 1):
# Get initial user message
initial_message = await get_initial_user_message(config, conversation_id)
if initial_message:
truncated_message = truncate_message(initial_message, 100)
else:
truncated_message = '[No user message found]'
# Format creation time
created_time = datetime.fromtimestamp(creation_time).isoformat()
print_formatted_text(
HTML(
f'<ansigreen>{i}.</ansigreen> '
f'<ansiyellow>[{conversation_id}]</ansiyellow> '
f'<ansiwhite>[Created: {created_time}]</ansiwhite>\n'
f' Initial message: {truncated_message}\n'
)
)
async def list_conversations(
config: OpenHandsConfig, page_id: str | None = None
) -> None:
"""List recent conversations with pagination."""
try:
conversation_store = await FileConversationStore.get_instance(config, None)
result = await conversation_store.search(page_id=page_id, limit=10)
if not result.results:
print_formatted_text('No conversations found.')
return
print_formatted_text('\n📋 Recent Conversations:')
print_formatted_text('=' * 80)
for i, conv in enumerate(result.results, 1):
# Get initial user message
initial_message = await get_initial_user_message(
config, conv.conversation_id
)
truncated_message = truncate_message(initial_message, 100)
created_at = conv.created_at or 'Unknown'
print_formatted_text(
f'{i:2d}. [{conv.conversation_id}] [{created_at}] '
f'Initial user message: {truncated_message}'
)
print_formatted_text('=' * 80)
# Handle pagination
if result.next_page_id:
action = cli_confirm(
config,
'More conversations available',
['Next page', 'Previous page', 'Go back'],
)
if action == 0: # Next page
await list_conversations(config, result.next_page_id)
elif action == 1: # Previous page (simplified - just go back to first page)
await list_conversations(config, None)
except Exception as e:
print_formatted_text(f'❌ Error listing conversations: {e}')
async def view_conversation_details(config: OpenHandsConfig) -> None:
"""View details of a specific conversation."""
conversation_id = await collect_input(config, 'Enter conversation ID:')
if not conversation_id:
return
try:
# Get all user messages from the conversation
user_messages = await get_user_messages_from_conversation(
config, conversation_id
)
if not user_messages:
print_formatted_text(
f'No user messages found in conversation {conversation_id}'
)
return
print_formatted_text(f'\n💬 User Messages in Conversation {conversation_id}:')
print_formatted_text('=' * 80)
for i, message in enumerate(user_messages, 1):
timestamp = message.get('timestamp', 'Unknown')
content = message.get('content', '')
# Replace newlines with visual symbols for better display
content = content.replace('\n', '').replace('\r', '')
print_formatted_text(f'{i:2d}. [{timestamp}] {content}')
print_formatted_text('=' * 80)
except Exception as e:
print_formatted_text(f'❌ Error viewing conversation details: {e}')
async def get_initial_user_message(
config: OpenHandsConfig, conversation_id: str
) -> str:
"""Get the initial user message from a conversation."""
try:
from openhands.events import EventSource
from openhands.events.action.message import MessageAction
from openhands.events.event_store import EventStore
from openhands.storage import get_file_store
file_store = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
)
event_store = EventStore(
sid=conversation_id,
file_store=file_store,
user_id=None,
)
# Search for the first user message
for event in event_store.search_events(start_id=0, limit=50):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event.content
return 'No initial message found'
except Exception:
return 'Error loading message'
async def get_user_messages_from_conversation(
config: OpenHandsConfig, conversation_id: str
) -> list[dict]:
"""Get all user messages from a conversation."""
try:
from openhands.events import EventSource
from openhands.events.action.message import MessageAction
from openhands.events.event_store import EventStore
from openhands.storage import get_file_store
file_store = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
)
event_store = EventStore(
sid=conversation_id,
file_store=file_store,
user_id=None,
)
user_messages = []
for event in event_store.search_events(start_id=0):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
user_messages.append(
{
'timestamp': event.timestamp if event.timestamp else 'Unknown',
'content': event.content,
}
)
return user_messages
except Exception as e:
print_formatted_text(f'Error loading messages: {e}')
return []
def truncate_message(message: str, max_length: int) -> str:
"""Truncate a message to a maximum length and replace newlines with visual symbols."""
# Replace actual newlines with visual newline symbols
message = message.replace('\n', '').replace('\r', '')
if len(message) <= max_length:
return message
return message[:max_length] + '...'

View File

@@ -22,6 +22,7 @@ from openhands.cli.shell_config import (
aliases_exist_in_shell_config,
mark_alias_setup_declined,
)
from openhands.cli.spinner import display_initialization_animation
from openhands.cli.tui import (
UsageMetrics,
cli_confirm,
@@ -29,7 +30,6 @@ from openhands.cli.tui import (
display_banner,
display_event,
display_initial_user_prompt,
display_initialization_animation,
display_runtime_initialization_message,
display_welcome_message,
read_confirmation_input,

104
openhands/cli/spinner.py Normal file
View File

@@ -0,0 +1,104 @@
"""Unified spinner utility for CLI operations."""
import asyncio
import sys
import threading
import time
class Spinner:
"""A unified spinner utility that works with both threading and asyncio events."""
FRAMES = ['', '', '', '', '', '', '', '', '', '']
def __init__(self, text: str = 'Loading...', use_ansi_colors: bool = False):
"""Initialize spinner with text and optional ANSI color formatting.
Args:
text: Text to display alongside the spinner
use_ansi_colors: Whether to use ANSI color codes for formatting
"""
self.text = text
self.use_ansi_colors = use_ansi_colors
self._frame_index = 0
def _get_formatted_text(self) -> str:
"""Get the formatted spinner text."""
frame = self.FRAMES[self._frame_index % len(self.FRAMES)]
if self.use_ansi_colors:
return f'\033[s\033[J\033[38;2;255;215;0m[{frame}] {self.text}\033[0m\033[u\033[1A'
else:
return f'\r{frame} {self.text}'
def _clear_line(self) -> None:
"""Clear the spinner line."""
if self.use_ansi_colors:
sys.stdout.write('\r' + ' ' * (len(self.text) + 10) + '\r')
else:
print('\r' + ' ' * (len(self.text) + 10) + '\r', end='', flush=True)
def show_with_threading_event(self, stop_event: threading.Event) -> None:
"""Show spinner using a threading.Event for control.
Args:
stop_event: Threading event to signal when to stop the spinner
"""
self._frame_index = 0
while not stop_event.is_set():
if self.use_ansi_colors:
sys.stdout.write('\n')
sys.stdout.write(self._get_formatted_text())
sys.stdout.flush()
else:
print(self._get_formatted_text(), end='', flush=True)
time.sleep(0.1)
self._frame_index += 1
self._clear_line()
sys.stdout.flush()
def show_with_asyncio_event(self, is_loaded: asyncio.Event) -> None:
"""Show spinner using an asyncio.Event for control.
Args:
is_loaded: Asyncio event to signal when to stop the spinner
"""
self._frame_index = 0
while not is_loaded.is_set():
if self.use_ansi_colors:
sys.stdout.write('\n')
sys.stdout.write(self._get_formatted_text())
sys.stdout.flush()
else:
print(self._get_formatted_text(), end='', flush=True)
time.sleep(0.1)
self._frame_index += 1
self._clear_line()
sys.stdout.flush()
def show_loading_spinner(
stop_event: threading.Event, text: str = 'Loading conversations...'
) -> None:
"""Show a loading spinner animation using threading.Event.
Args:
stop_event: Threading event to signal when to stop the spinner
text: Text to display alongside the spinner
"""
spinner = Spinner(text, use_ansi_colors=False)
spinner.show_with_threading_event(stop_event)
def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> None:
"""Display initialization animation using asyncio.Event.
Args:
text: Text to display alongside the spinner
is_loaded: Asyncio event to signal when to stop the spinner
"""
spinner = Spinner(text, use_ansi_colors=True)
spinner.show_with_asyncio_event(is_loaded)

View File

@@ -84,6 +84,7 @@ COMMANDS = {
'/settings': 'Display and modify current settings',
'/resume': 'Resume the agent when paused',
'/mcp': 'Manage MCP server configuration and view errors',
'/conversations': 'View conversation history and user messages',
}
print_lock = threading.Lock()
@@ -129,23 +130,6 @@ def display_runtime_initialization_message(runtime: str) -> None:
print_formatted_text('')
def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> None:
ANIMATION_FRAMES = ['', '', '', '', '', '', '', '', '', '']
i = 0
while not is_loaded.is_set():
sys.stdout.write('\n')
sys.stdout.write(
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
)
sys.stdout.flush()
time.sleep(0.1)
i += 1
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
sys.stdout.flush()
def display_banner(session_id: str) -> None:
print_formatted_text(
HTML(r"""<gold>

View File

@@ -4,8 +4,12 @@ import pytest
from prompt_toolkit.formatted_text import HTML
from openhands.cli.commands import (
display_conversation_page,
display_mcp_servers,
get_conversation_folders_by_time,
get_initial_user_message,
handle_commands,
handle_conv_command,
handle_exit_command,
handle_help_command,
handle_init_command,
@@ -14,6 +18,8 @@ from openhands.cli.commands import (
handle_resume_command,
handle_settings_command,
handle_status_command,
truncate_message,
view_conversation_details,
)
from openhands.cli.tui import UsageMetrics
from openhands.core.config import OpenHandsConfig
@@ -160,6 +166,20 @@ class TestHandleCommands:
assert reload_microagents is False
assert new_session is False
@pytest.mark.asyncio
@patch('openhands.cli.commands.handle_conv_command')
async def test_handle_conversations_command(
self, mock_handle_conv, mock_dependencies
):
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/conversations', **mock_dependencies
)
mock_handle_conv.assert_called_once_with(mock_dependencies['config'])
assert close_repl is False
assert reload_microagents is False
assert new_session is False
@pytest.mark.asyncio
async def test_handle_unknown_command(self, mock_dependencies):
user_message = 'Hello, this is not a command'
@@ -635,3 +655,165 @@ class TestMCPErrorHandling:
handle_mcp_errors_command()
mock_display_errors.assert_called_once()
class TestConversationCommands:
"""Test conversation history commands."""
@pytest.mark.asyncio
@patch('openhands.cli.commands.get_conversation_folders_by_time')
@patch('openhands.cli.commands.print_formatted_text')
async def test_handle_conv_command_no_conversations(
self, mock_print, mock_get_folders
):
"""Test handle_conv_command with no conversations found."""
config = MagicMock(spec=OpenHandsConfig)
mock_get_folders.return_value = []
await handle_conv_command(config)
mock_get_folders.assert_called_once_with(config)
# Check that the function was called with the expected message
calls = [call.args[0] for call in mock_print.call_args_list]
assert any('No conversations found' in str(call) for call in calls)
@pytest.mark.asyncio
@patch('openhands.cli.commands.get_conversation_folders_by_time')
@patch('openhands.cli.commands.display_conversation_page')
@patch('openhands.cli.commands.cli_confirm')
@patch('openhands.cli.commands.print_formatted_text')
async def test_handle_conv_command_with_conversations(
self, mock_print, mock_cli_confirm, mock_display_page, mock_get_folders
):
"""Test handle_conv_command with conversations and go back action."""
config = MagicMock(spec=OpenHandsConfig)
mock_get_folders.return_value = [
('conv-1', 1640995200.0),
('conv-2', 1640995100.0),
]
mock_cli_confirm.return_value = 1 # Go back (assuming it's the last option)
await handle_conv_command(config)
mock_get_folders.assert_called_once_with(config)
mock_display_page.assert_called_once()
mock_cli_confirm.assert_called_once()
def test_get_conversation_folders_by_time_success(self):
"""Test get_conversation_folders_by_time with successful folder retrieval."""
# This is more of an integration test - we'll just test that the function
# handles the case where no sessions directory exists gracefully
config = MagicMock(spec=OpenHandsConfig)
config.file_store = 'local'
config.file_store_path = '/nonexistent/path'
result = get_conversation_folders_by_time(config)
# Since the path doesn't exist, it should return an empty list
assert result == []
@patch('openhands.storage.get_file_store')
def test_get_conversation_folders_by_time_no_sessions(self, mock_get_file_store):
"""Test get_conversation_folders_by_time with no sessions directory."""
config = MagicMock(spec=OpenHandsConfig)
mock_get_file_store.side_effect = Exception('No sessions')
result = get_conversation_folders_by_time(config)
assert result == []
@pytest.mark.asyncio
@patch('openhands.cli.commands.get_initial_user_message')
@patch('openhands.cli.commands.print_formatted_text')
async def test_display_conversation_page(self, mock_print, mock_get_message):
"""Test display_conversation_page with conversation data."""
config = MagicMock(spec=OpenHandsConfig)
conversations = [('conv-1', 1640995200.0), ('conv-2', 1640995100.0)]
mock_get_message.side_effect = ['Hello world', 'Fix this bug']
await display_conversation_page(config, conversations, 1)
assert mock_get_message.call_count == 2
assert mock_print.call_count >= 3 # Header + 2 conversations
@pytest.mark.asyncio
@patch('openhands.cli.commands.collect_input')
@patch('openhands.cli.commands.get_user_messages_from_conversation')
@patch('openhands.cli.commands.print_formatted_text')
async def test_view_conversation_details_success(
self, mock_print, mock_get_messages, mock_collect_input
):
"""Test view_conversation_details with successful data retrieval."""
config = MagicMock(spec=OpenHandsConfig)
mock_collect_input.return_value = 'conv-123'
mock_get_messages.return_value = [
('2023-01-01T10:00:00Z', 'Hello'),
('2023-01-01T10:05:00Z', 'How are you?'),
]
await view_conversation_details(config)
mock_collect_input.assert_called_once_with(config, 'Enter conversation ID:')
mock_get_messages.assert_called_once_with(config, 'conv-123')
assert mock_print.call_count >= 3 # Header + 2 messages
@pytest.mark.asyncio
@patch('openhands.cli.commands.collect_input')
async def test_view_conversation_details_cancelled(self, mock_collect_input):
"""Test view_conversation_details when user cancels input."""
config = MagicMock(spec=OpenHandsConfig)
mock_collect_input.return_value = None # User cancelled
await view_conversation_details(config)
mock_collect_input.assert_called_once_with(config, 'Enter conversation ID:')
@pytest.mark.asyncio
async def test_get_initial_user_message_success(self):
"""Test get_initial_user_message with successful message retrieval."""
config = MagicMock(spec=OpenHandsConfig)
config.file_store = 'local'
config.file_store_path = '/tmp/test'
conversation_id = 'conv-123'
result = await get_initial_user_message(config, conversation_id)
# Since we don't have actual conversation data, it should return the error message
assert result in ['No initial message found', 'Error loading message']
@pytest.mark.asyncio
@patch('openhands.storage.get_file_store')
async def test_get_initial_user_message_error(self, mock_get_file_store):
"""Test get_initial_user_message with error handling."""
config = MagicMock(spec=OpenHandsConfig)
conversation_id = 'conv-123'
mock_get_file_store.side_effect = Exception('File not found')
result = await get_initial_user_message(config, conversation_id)
assert result == 'Error loading message'
def test_truncate_message_short(self):
"""Test truncate_message with short message."""
message = 'Hello world'
result = truncate_message(message, 100)
assert result == 'Hello world'
def test_truncate_message_long(self):
"""Test truncate_message with long message."""
message = 'This is a very long message that should be truncated'
result = truncate_message(message, 20)
assert result == 'This is a very long ...'
assert len(result) == 23 # 20 + '...'
def test_truncate_message_with_newlines(self):
"""Test truncate_message with newlines."""
message = 'Hello\nworld\rtest'
result = truncate_message(message, 100)
assert result == 'Hello↵world↵test'
# Test with truncation and newlines
message = 'Hello\nworld\nthis is a long message'
result = truncate_message(message, 15)
assert result == 'Hello↵world↵thi...'