diff --git a/cli/knowledge.md b/cli/knowledge.md index 327ed89a6..d01944af2 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -360,6 +360,55 @@ The cleanest solution is to use a direct ternary with separate `` elements **Note:** Helper components like `ConditionalText` are not recommended as they add unnecessary abstraction without providing meaningful benefits. The direct ternary pattern is clearer and easier to maintain. +### Combining ShimmerText with Other Inline Elements + +**Problem**: When you need to display multiple inline elements alongside a dynamically updating component like `ShimmerText` (e.g., showing elapsed time + shimmer text), using `` causes reconciliation errors. + +**Why `` fails:** + +```tsx +// ❌ PROBLEMATIC: ShimmerText in a with other elements causes reconciliation errors + + {elapsedSeconds}s + + + + +``` + +The issue occurs because: +1. ShimmerText constantly updates its internal state (pulse animation) +2. Each update re-renders with different `` structures +3. OpenTUI's reconciler struggles to match up the changing children inside the `` +4. Results in "Component of type 'span' must be created inside of a text node" error + +**✅ Solution: Use a Fragment with inline spans** + +Instead of using ``, return a Fragment containing all inline elements: + +```tsx +// Component returns Fragment with inline elements +if (elapsedSeconds > 0) { + return ( + <> + {elapsedSeconds}s + + + ) +} + +// Parent wraps in +{statusIndicatorNode} +``` + +**Key principles:** +- Avoid wrapping dynamically updating components (like ShimmerText) in `` elements +- Use Fragments to group inline elements that will be wrapped in `` by the parent +- Include spacing as part of the text content (e.g., `"{elapsedSeconds}s "` with trailing space) +- Let the parent component provide the `` wrapper for proper rendering + +This pattern works because all elements remain inline within a single stable `` container, avoiding the reconciliation issues that occur when ShimmerText updates inside a ``. + ### The "Text Must Be Created Inside of a Text Node" Error **Error message:** diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index ffefa3b4b..921c6fd7b 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1,3 +1,4 @@ +import { TextAttributes } from '@opentui/core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -9,13 +10,18 @@ import { MultilineInput, type MultilineInputHandle, } from './components/multiline-input' -import { StatusIndicator, useHasStatus } from './components/status-indicator' +import { + StatusIndicator, + StatusElapsedTime, + getStatusIndicatorState, +} from './components/status-indicator' import { SuggestionMenu } from './components/suggestion-menu' import { SLASH_COMMANDS } from './data/slash-commands' import { useAgentValidation } from './hooks/use-agent-validation' import { useAuthState } from './hooks/use-auth-state' import { useChatInput } from './hooks/use-chat-input' import { useClipboard } from './hooks/use-clipboard' +import { useConnectionStatus } from './hooks/use-connection-status' import { useElapsedTime } from './hooks/use-elapsed-time' import { useExitHandler } from './hooks/use-exit-handler' import { useInputHistory } from './hooks/use-input-history' @@ -34,6 +40,7 @@ import { createChatScrollAcceleration } from './utils/chat-scroll-accel' import { formatQueuedPreview } from './utils/helpers' import { loadLocalAgents } from './utils/local-agent-registry' import { buildMessageTree } from './utils/message-tree-utils' +import { computeInputLayoutMetrics } from './utils/text-layout' import { createMarkdownPalette } from './utils/theme-system' import { BORDER_CHARS } from './utils/ui-constants' @@ -71,7 +78,7 @@ export const Chat = ({ const scrollRef = useRef(null) const inputRef = useRef(null) - const { terminalWidth, separatorWidth } = useTerminalDimensions() + const { separatorWidth } = useTerminalDimensions() const theme = useTheme() const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) @@ -109,7 +116,6 @@ export const Chat = ({ agentMode, setAgentMode, toggleAgentMode, - hasReceivedPlanResponse, setHasReceivedPlanResponse, lastMessageMode, setLastMessageMode, @@ -175,7 +181,6 @@ export const Chat = ({ const { isAuthenticated, setIsAuthenticated, - user, setUser, handleLoginSuccess, logoutMutation, @@ -204,6 +209,7 @@ export const Chat = ({ const sendMessageRef = useRef() const { clipboardMessage } = useClipboard() + const isConnected = useConnectionStatus() const mainAgentTimer = useElapsedTime() const timerStartTime = mainAgentTimer.startTime @@ -216,9 +222,50 @@ export const Chat = ({ activeSubagentsRef.current = activeSubagents }, [activeSubagents]) + const isUserCollapsingRef = useRef(false) + + const handleCollapseToggle = useCallback( + (id: string) => { + const wasCollapsed = collapsedAgents.has(id) + + // Set flag to prevent auto-scroll during user-initiated collapse + isUserCollapsingRef.current = true + setCollapsedAgents((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + + // Reset flag after state update completes + setTimeout(() => { + isUserCollapsingRef.current = false + }, 0) + + setUserOpenedAgents((prev) => { + const next = new Set(prev) + if (wasCollapsed) { + next.add(id) + } else { + next.delete(id) + } + return next + }) + }, + [collapsedAgents, setCollapsedAgents, setUserOpenedAgents], + ) + + const isUserCollapsing = useCallback(() => { + return isUserCollapsingRef.current + }, []) + const { scrollToLatest, scrollboxProps, isAtBottom } = useChatScrollbox( scrollRef, messages, + isUserCollapsing, ) const inertialScrollAcceleration = useMemo( @@ -237,6 +284,8 @@ export const Chat = ({ setInputValue, }) + const [scrollIndicatorHovered, setScrollIndicatorHovered] = useState(false) + const { slashContext, mentionContext, @@ -305,15 +354,13 @@ export const Chat = ({ const { queuedMessages, - isStreaming, - isWaitingForResponse, + streamStatus, streamMessageIdRef, addToQueue, startStreaming, stopStreaming, - setIsWaitingForResponse, + setStreamStatus, setCanProcessQueue, - setIsStreaming, } = useMessageQueue( (content: string) => sendMessageRef.current?.({ content, agentMode }) ?? Promise.resolve(), @@ -321,29 +368,12 @@ export const Chat = ({ activeAgentStreamsRef, ) - const handleTimerEvent = useCallback( - (event: SendMessageTimerEvent) => { - const payload = { - event: 'cli_main_agent_timer', - timerEventType: event.type, - agentId: agentId ?? 'main', - messageId: event.messageId, - startedAt: event.startedAt, - ...(event.type === 'stop' - ? { - finishedAt: event.finishedAt, - elapsedMs: event.elapsedMs, - outcome: event.outcome, - } - : {}), - } - const message = - event.type === 'start' - ? 'Main agent timer started' - : `Main agent timer stopped (${event.outcome})` - }, - [agentId], - ) + // Derive boolean flags from streamStatus for convenience + const isWaitingForResponse = streamStatus === 'waiting' + const isStreaming = streamStatus !== 'idle' + + // Timer events are currently tracked but not used for UI updates + // Future: Could be used for analytics or debugging const { sendMessage, clearMessages } = useSendMessage({ messages, @@ -359,10 +389,9 @@ export const Chat = ({ isChainInProgressRef, setActiveSubagents, setIsChainInProgress, - setIsWaitingForResponse, + setStreamStatus, startStreaming, stopStreaming, - setIsStreaming, setCanProcessQueue, abortControllerRef, agentId, @@ -370,7 +399,7 @@ export const Chat = ({ mainAgentTimer, scrollToLatest, availableWidth: separatorWidth, - onTimerEvent: handleTimerEvent, + onTimerEvent: () => {}, // No-op for now setHasReceivedPlanResponse, lastMessageMode, setLastMessageMode, @@ -388,15 +417,6 @@ export const Chat = ({ sendMessageRef, }) - // Status is active when waiting for response or streaming - const isStatusActive = isWaitingForResponse || isStreaming - const hasStatus = useHasStatus({ - isActive: isStatusActive, - clipboardMessage, - timerStartTime, - nextCtrlCWillExit, - }) - const handleSubmit = useCallback( () => routeUserPrompt({ @@ -493,15 +513,67 @@ export const Chat = ({ ) : null const shouldShowQueuePreview = queuedMessages.length > 0 - const shouldShowStatusLine = Boolean(hasStatus || shouldShowQueuePreview) + const queuePreviewTitle = useMemo(() => { + if (!shouldShowQueuePreview) return undefined + const previewWidth = Math.max(30, separatorWidth - 20) + return formatQueuedPreview(queuedMessages, previewWidth) + }, [queuedMessages, separatorWidth, shouldShowQueuePreview]) + const hasSlashSuggestions = + slashContext.active && slashSuggestionItems.length > 0 + const hasMentionSuggestions = + !slashContext.active && + mentionContext.active && + agentSuggestionItems.length > 0 + const hasSuggestionMenu = hasSlashSuggestions || hasMentionSuggestions + const showAgentStatusLine = showAgentDisplayName && loadedAgentsData + + const inputLayoutMetrics = useMemo(() => { + const text = inputValue ?? '' + const layoutContent = text.length > 0 ? text : ' ' + const safeCursor = Math.max( + 0, + Math.min(cursorPosition, layoutContent.length), + ) + const cursorProbe = + safeCursor >= layoutContent.length + ? layoutContent + : layoutContent.slice(0, safeCursor) + const cols = Math.max(1, inputWidth - 4) + return computeInputLayoutMetrics({ + layoutContent, + cursorProbe, + cols, + maxHeight: 5, + }) + }, [inputValue, cursorPosition, inputWidth]) + const isMultilineInput = inputLayoutMetrics.heightLines > 1 + const shouldCenterInputVertically = + !hasSuggestionMenu && !showAgentStatusLine && !isMultilineInput + const statusIndicatorState = getStatusIndicatorState({ + clipboardMessage, + streamStatus, + nextCtrlCWillExit, + isConnected, + }) + const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle' + + const shouldShowStatusLine = + hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom const statusIndicatorNode = ( + ) + + const elapsedTimeNode = ( + ) @@ -577,6 +649,7 @@ export const Chat = ({ streamingAgents={streamingAgents} isWaitingForResponse={isWaitingForResponse} timerStartTime={timerStartTime} + onCollapseToggle={handleCollapseToggle} setCollapsedAgents={setCollapsedAgents} setFocusedAgentId={setFocusedAgentId} userOpenedAgents={userOpenedAgents} @@ -598,34 +671,91 @@ export const Chat = ({ {shouldShowStatusLine && ( - - {hasStatus && statusIndicatorNode} - {shouldShowQueuePreview && ( - - {' '} - {formatQueuedPreview( - queuedMessages, - Math.max(30, terminalWidth - 25), - )}{' '} - - )} - + {/* Main status line: status indicator | scroll indicator | elapsed time */} + + {/* Left section - status indicator */} + + {statusIndicatorNode} + + + {/* Center section - scroll indicator (always centered) */} + + {!isAtBottom && ( + scrollToLatest()} + onMouseOver={() => setScrollIndicatorHovered(true)} + onMouseOut={() => setScrollIndicatorHovered(false)} + > + + + {scrollIndicatorHovered ? '↓ Scroll to bottom ↓' : '↓'} + + + + )} + + + {/* Right section - elapsed time */} + + {elapsedTimeNode} + + )} + + {/* Wrap the input row in a single OpenTUI border so the toggle stays inside the flex layout. + The queue preview is injected via the border title rather than custom text nodes, which + keeps the border coupled to the content height while preserving the inline preview look. */} - {slashContext.active && slashSuggestionItems.length > 0 ? ( + {hasSlashSuggestions ? ( ) : null} - {!slashContext.active && - mentionContext.active && - agentSuggestionItems.length > 0 ? ( + {hasMentionSuggestions ? ( - - - - + + + + + + + {/* Agent status line - right-aligned under toggle */} + {showAgentStatusLine && ( + + + Agent: {agentDisplayName} + + + )} - {/* Agent status line - right-aligned under toggle */} - {showAgentDisplayName && loadedAgentsData && ( - - - Agent: {agentDisplayName} - - - )} {/* Login Modal Overlay - show when not authenticated and done checking */} diff --git a/cli/src/components/__tests__/status-indicator.test.tsx b/cli/src/components/__tests__/status-indicator.test.tsx new file mode 100644 index 000000000..bc5722de7 --- /dev/null +++ b/cli/src/components/__tests__/status-indicator.test.tsx @@ -0,0 +1,124 @@ +import { describe, test, expect } from 'bun:test' + +import { getStatusIndicatorState } from '../status-indicator' +import type { StatusIndicatorStateArgs } from '../status-indicator' + +describe('StatusIndicator state logic', () => { + describe('getStatusIndicatorState', () => { + const baseArgs: StatusIndicatorStateArgs = { + clipboardMessage: null, + streamStatus: 'idle', + nextCtrlCWillExit: false, + isConnected: true, + } + + test('returns idle state when no special conditions', () => { + const state = getStatusIndicatorState(baseArgs) + expect(state.kind).toBe('idle') + }) + + test('returns ctrlC state when nextCtrlCWillExit is true (highest priority)', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + nextCtrlCWillExit: true, + clipboardMessage: 'Some message', + streamStatus: 'streaming', + isConnected: false, + }) + expect(state.kind).toBe('ctrlC') + }) + + test('returns clipboard state when message exists (second priority)', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + clipboardMessage: 'Copied to clipboard!', + streamStatus: 'streaming', + isConnected: false, + }) + expect(state.kind).toBe('clipboard') + if (state.kind === 'clipboard') { + expect(state.message).toBe('Copied to clipboard!') + } + }) + + test('returns connecting state when not connected (third priority)', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + isConnected: false, + streamStatus: 'streaming', + }) + expect(state.kind).toBe('connecting') + }) + + test('returns waiting state when streamStatus is waiting', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + streamStatus: 'waiting', + }) + expect(state.kind).toBe('waiting') + }) + + test('returns streaming state when streamStatus is streaming', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + streamStatus: 'streaming', + }) + expect(state.kind).toBe('streaming') + }) + + test('handles empty clipboard message as falsy', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + clipboardMessage: '', + streamStatus: 'streaming', + }) + // Empty string is falsy, should fall through to streaming state + expect(state.kind).toBe('streaming') + }) + + describe('state priority order', () => { + test('nextCtrlCWillExit beats clipboard', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + nextCtrlCWillExit: true, + clipboardMessage: 'Test', + }) + expect(state.kind).toBe('ctrlC') + }) + + test('clipboard beats connecting', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + clipboardMessage: 'Test', + isConnected: false, + }) + expect(state.kind).toBe('clipboard') + }) + + test('connecting beats waiting', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + isConnected: false, + streamStatus: 'waiting', + }) + expect(state.kind).toBe('connecting') + }) + + test('waiting beats streaming', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + streamStatus: 'waiting', + }) + expect(state.kind).toBe('waiting') + }) + + test('streaming beats idle', () => { + const state = getStatusIndicatorState({ + ...baseArgs, + streamStatus: 'streaming', + }) + expect(state.kind).toBe('streaming') + }) + }) + }) +}) diff --git a/cli/src/components/__tests__/status-indicator.timer.test.tsx b/cli/src/components/__tests__/status-indicator.timer.test.tsx index 9274e4542..7bfbd5f60 100644 --- a/cli/src/components/__tests__/status-indicator.timer.test.tsx +++ b/cli/src/components/__tests__/status-indicator.timer.test.tsx @@ -1,75 +1,162 @@ -import { - describe, - test, - expect, - beforeEach, - afterEach, - mock, - spyOn, -} from 'bun:test' +import { describe, test, expect } from 'bun:test' import React from 'react' -import { StatusIndicator } from '../status-indicator' +import { StatusIndicator, StatusElapsedTime } from '../status-indicator' import '../../state/theme-store' // Initialize theme store import { renderToStaticMarkup } from 'react-dom/server' +import { getStatusIndicatorState } from '../status-indicator' -import * as codebuffClient from '../../utils/codebuff-client' +describe('StatusIndicator state transitions', () => { -const createTimerStartTime = ( - elapsedSeconds: number, - started: boolean, -): number | null => - started ? Date.now() - elapsedSeconds * 1000 : null + describe('StatusIndicator text states', () => { + test('shows "thinking..." when waiting for first response (streamStatus = waiting)', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) -describe('StatusIndicator timer rendering', () => { - let getClientSpy: ReturnType + // ShimmerText renders individual characters in spans + expect(markup).toContain('t') + expect(markup).toContain('h') + expect(markup).toContain('i') + expect(markup).toContain('n') + expect(markup).toContain('k') + expect(markup).not.toContain('w') // not "working" + }) - beforeEach(() => { - getClientSpy = spyOn(codebuffClient, 'getCodebuffClient').mockReturnValue({ - checkConnection: mock(async () => true), - } as any) + test('shows "working..." when streaming content (streamStatus = streaming)', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) + + // ShimmerText renders individual characters in spans + expect(markup).toContain('w') + expect(markup).toContain('o') + expect(markup).toContain('r') + expect(markup).toContain('k') + }) + + test('shows nothing when inactive (streamStatus = idle)', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBe('') + }) }) - afterEach(() => { - getClientSpy.mockRestore() + describe('Priority states', () => { + test('nextCtrlCWillExit takes highest priority', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Press Ctrl-C again to exit') + expect(markup).not.toContain('Copied!') + expect(markup).not.toContain('thinking') + expect(markup).not.toContain('working') + }) + + test('clipboard message takes priority over streaming states', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Copied!') + // Shimmer text would contain individual characters, but clipboard message doesn't + }) }) - test('shows elapsed seconds when timer is active', () => { - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('5s') - - const inactiveMarkup = renderToStaticMarkup( - , - ) - - expect(inactiveMarkup).toBe('') + describe('Connectivity states', () => { + test('shows "connecting..." shimmer when offline and idle', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('c') + expect(markup).toContain('o') + expect(markup).toContain('n') + }) + + test('getStatusIndicatorState reports connecting state when offline', () => { + const state = getStatusIndicatorState({ + clipboardMessage: null, + streamStatus: 'idle', + nextCtrlCWillExit: false, + isConnected: false, + }) + + expect(state.kind).toBe('connecting') + }) }) - test('clipboard message takes priority over timer output', () => { - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Copied!') - expect(markup).not.toContain('12s') + describe('StatusElapsedTime', () => { + test('shows nothing initially (useEffect not triggered in static render)', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) + + // Static rendering doesn't trigger useEffect, so elapsed time starts at 0 + // In real usage, useEffect updates the elapsed time after mount + expect(markup).toBe('') + }) + + test('shows nothing when inactive', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBe('') + }) + + test('shows nothing when timerStartTime is null', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBe('') + }) }) }) diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index b14ab0a89..77db736b0 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useRef, useState } from 'react' -import stringWidth from 'string-width' import { SegmentedControl } from './segmented-control' import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' import type { Segment } from './segmented-control' import type { AgentMode } from '../utils/constants' @@ -165,26 +165,14 @@ export const AgentModeToggle = ({ const [isCollapsedHovered, setIsCollapsedHovered] = useState(false) const hoverToggle = useHoverToggle() - const handleCollapsedClick = () => { - hoverToggle.clearAllTimers() - if (hoverToggle.isOpen) { - hoverToggle.closeNow(true) - } else { - hoverToggle.openNow() - } - } - const handleMouseOver = () => { - if (!hoverToggle.isOpen) setIsCollapsedHovered(true) - // Cancel any pending close and schedule open with delay hoverToggle.clearCloseTimer() hoverToggle.scheduleOpen() } const handleMouseOut = () => { - setIsCollapsedHovered(false) - // Schedule close using the hook's configured delay hoverToggle.scheduleClose() + setIsCollapsedHovered(false) } const handleSegmentClick = (id: string) => { @@ -204,45 +192,39 @@ export const AgentModeToggle = ({ hoverToggle.closeNow(true) } - const renderCollapsedState = () => { - const label = MODE_LABELS[mode] - const arrow = '< ' - const contentText = ` ${arrow}${label} ` - const contentWidth = stringWidth(contentText) - const horizontal = '─'.repeat(contentWidth) - - const borderColor = isCollapsedHovered ? theme.foreground : theme.border - + if (!hoverToggle.isOpen) { return ( { + hoverToggle.clearAllTimers() + hoverToggle.openNow() + }} + onMouseOver={() => { + setIsCollapsedHovered(true) + handleMouseOver() }} - onMouseDown={handleCollapsedClick} - onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} > - {`╭${horizontal}╮`} - - + {isCollapsedHovered ? ( - {` ${arrow}${label} `} + {`< ${MODE_LABELS[mode]}`} ) : ( - ` ${arrow}${label} ` + `< ${MODE_LABELS[mode]}` )} - - {`╰${horizontal}╯`} ) } - if (!hoverToggle.isOpen) { - return renderCollapsedState() - } - // Expanded state: delegate rendering to SegmentedControl const segments: Segment[] = buildExpandedSegments(mode) diff --git a/cli/src/components/elapsed-timer.tsx b/cli/src/components/elapsed-timer.tsx index c61d6fde5..c76d8a9cc 100644 --- a/cli/src/components/elapsed-timer.tsx +++ b/cli/src/components/elapsed-timer.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { TextAttributes } from '@opentui/core' import { useTheme } from '../hooks/use-theme' +import { formatElapsedTime } from '../utils/format-elapsed-time' interface ElapsedTimerProps { startTime: number | null @@ -54,7 +55,7 @@ export const ElapsedTimer = ({ return ( - {elapsedSeconds}s{suffix} + {formatElapsedTime(elapsedSeconds)}{suffix} ) } diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index a431598e5..148ca2404 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -35,6 +35,30 @@ const isReasoningTextBlock = (b: any): boolean => (typeof b.color === 'string' && (b.color.toLowerCase() === 'grey' || b.color.toLowerCase() === 'gray'))) +const isRenderableTimelineBlock = ( + block: ContentBlock | null | undefined, +): boolean => { + if (!block) { + return false + } + + if (block.type === 'tool') { + return (block as any).toolName !== 'end_turn' + } + + switch (block.type) { + case 'text': + case 'html': + case 'agent': + case 'agent-list': + case 'plan': + case 'mode-divider': + return true + default: + return false + } +} + interface MessageBlockProps { messageId: string blocks?: ContentBlock[] @@ -583,25 +607,12 @@ const AgentBody = memo( Boolean, ) as React.ReactNode[] if (nonNullGroupNodes.length > 0) { - const isRenderableBlock = (b: ContentBlock): boolean => { - if (b.type === 'tool') { - return (b as any).toolName !== 'end_turn' - } - switch (b.type) { - case 'text': - case 'html': - case 'agent': - case 'agent-list': - return true - default: - return false - } - } - - // Check for any subsequent renderable blocks without allocating a slice + const hasRenderableBefore = + start > 0 && + isRenderableTimelineBlock(nestedBlocks[start - 1] as any) let hasRenderableAfter = false for (let i = nestedIdx; i < nestedBlocks.length; i++) { - if (isRenderableBlock(nestedBlocks[i] as any)) { + if (isRenderableTimelineBlock(nestedBlocks[i] as any)) { hasRenderableAfter = true break } @@ -612,10 +623,7 @@ const AgentBody = memo( style={{ flexDirection: 'column', gap: 0, - // Avoid double spacing with the agent header, which already - // adds bottom padding. Only add top margin if this group is - // not the first rendered child. - marginTop: nodes.length === 0 ? 0 : 1, + marginTop: hasRenderableBefore ? 1 : 0, marginBottom: hasRenderableAfter ? 1 : 0, }} > @@ -1039,21 +1047,13 @@ const BlocksRenderer = memo( Boolean, ) as React.ReactNode[] if (nonNullGroupNodes.length > 0) { + const hasRenderableBefore = + start > 0 && + isRenderableTimelineBlock(sourceBlocks[start - 1] as any) // Check for any subsequent renderable blocks without allocating a slice let hasRenderableAfter = false for (let j = i; j < sourceBlocks.length; j++) { - const b = sourceBlocks[j] as any - if (b.type === 'tool') { - if ((b as any).toolName !== 'end_turn') { - hasRenderableAfter = true - break - } - } else if ( - b.type === 'text' || - b.type === 'html' || - b.type === 'agent' || - b.type === 'agent-list' - ) { + if (isRenderableTimelineBlock(sourceBlocks[j] as any)) { hasRenderableAfter = true break } @@ -1064,7 +1064,7 @@ const BlocksRenderer = memo( style={{ flexDirection: 'column', gap: 0, - marginTop: 1, + marginTop: hasRenderableBefore ? 1 : 0, marginBottom: hasRenderableAfter ? 1 : 0, }} > diff --git a/cli/src/components/message-renderer.tsx b/cli/src/components/message-renderer.tsx index 9faecd96e..109b752cf 100644 --- a/cli/src/components/message-renderer.tsx +++ b/cli/src/components/message-renderer.tsx @@ -25,6 +25,7 @@ interface MessageRendererProps { streamingAgents: Set isWaitingForResponse: boolean timerStartTime: number | null + onCollapseToggle: (id: string) => void setCollapsedAgents: React.Dispatch>> setFocusedAgentId: React.Dispatch> userOpenedAgents: Set @@ -45,6 +46,7 @@ export const MessageRenderer = (props: MessageRendererProps): ReactNode => { streamingAgents, isWaitingForResponse, timerStartTime, + onCollapseToggle, setCollapsedAgents, setFocusedAgentId, setUserOpenedAgents, @@ -52,31 +54,6 @@ export const MessageRenderer = (props: MessageRendererProps): ReactNode => { onBuildMax, } = props - const onToggleCollapsed = useCallback( - (id: string) => { - const wasCollapsed = collapsedAgents.has(id) - setCollapsedAgents((prev) => { - const next = new Set(prev) - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } - return next - }) - setUserOpenedAgents((prev) => { - const next = new Set(prev) - if (wasCollapsed) { - next.add(id) - } else { - next.delete(id) - } - return next - }) - }, - [collapsedAgents, setCollapsedAgents, setUserOpenedAgents], - ) - return ( <> {topLevelMessages.map((message, idx) => { @@ -99,7 +76,7 @@ export const MessageRenderer = (props: MessageRendererProps): ReactNode => { setFocusedAgentId={setFocusedAgentId} isWaitingForResponse={isWaitingForResponse} timerStartTime={timerStartTime} - onToggleCollapsed={onToggleCollapsed} + onToggleCollapsed={onCollapseToggle} onBuildFast={onBuildFast} onBuildMax={onBuildMax} /> diff --git a/cli/src/components/status-indicator.tsx b/cli/src/components/status-indicator.tsx index 43f1376f4..3897cabf0 100644 --- a/cli/src/components/status-indicator.tsx +++ b/cli/src/components/status-indicator.tsx @@ -1,104 +1,158 @@ import React, { useEffect, useState } from 'react' -import { ElapsedTimer } from './elapsed-timer' import { ShimmerText } from './shimmer-text' import { useTheme } from '../hooks/use-theme' -import { getCodebuffClient } from '../utils/codebuff-client' +import { formatElapsedTime } from '../utils/format-elapsed-time' +import type { StreamStatus } from '../hooks/use-message-queue' -const useConnectionStatus = () => { - const [isConnected, setIsConnected] = useState(true) +// Shimmer animation interval for status text (milliseconds) +const SHIMMER_INTERVAL_MS = 160 - useEffect(() => { - const checkConnection = async () => { - const client = getCodebuffClient() - if (!client) { - setIsConnected(false) - return - } - - try { - const connected = await client.checkConnection() - setIsConnected(connected) - } catch (error) { - setIsConnected(false) - } - } +export type StatusIndicatorState = + | { kind: 'idle' } + | { kind: 'clipboard'; message: string } + | { kind: 'ctrlC' } + | { kind: 'connecting' } + | { kind: 'waiting' } + | { kind: 'streaming' } - checkConnection() +export type StatusIndicatorStateArgs = { + clipboardMessage?: string | null + streamStatus: StreamStatus + nextCtrlCWillExit: boolean + isConnected: boolean +} + +/** + * Determines the status indicator state based on current context. + * + * State priority (highest to lowest): + * 1. nextCtrlCWillExit - User pressed Ctrl+C once, warn about exit + * 2. clipboardMessage - Temporary feedback for clipboard operations + * 3. connecting - Not connected to backend + * 4. waiting - Waiting for AI response to start + * 5. streaming - AI is actively responding + * 6. idle - No activity + * + * @param args - Context for determining indicator state + * @returns The appropriate state indicator + */ +export const getStatusIndicatorState = ({ + clipboardMessage, + streamStatus, + nextCtrlCWillExit, + isConnected, +}: StatusIndicatorStateArgs): StatusIndicatorState => { + if (nextCtrlCWillExit) { + return { kind: 'ctrlC' } + } - const interval = setInterval(checkConnection, 30000) + if (clipboardMessage) { + return { kind: 'clipboard', message: clipboardMessage } + } - return () => clearInterval(interval) - }, []) + if (!isConnected) { + return { kind: 'connecting' } + } - return isConnected + if (streamStatus === 'waiting') { + return { kind: 'waiting' } + } + + if (streamStatus === 'streaming') { + return { kind: 'streaming' } + } + + return { kind: 'idle' } +} + +type StatusIndicatorProps = StatusIndicatorStateArgs & { + timerStartTime: number | null } export const StatusIndicator = ({ clipboardMessage, - isActive = false, - isWaitingForResponse = false, + streamStatus, timerStartTime, nextCtrlCWillExit, -}: { - clipboardMessage?: string | null - isActive?: boolean - isWaitingForResponse?: boolean - timerStartTime: number | null - nextCtrlCWillExit: boolean -}) => { + isConnected, +}: StatusIndicatorProps) => { const theme = useTheme() - const isConnected = useConnectionStatus() - - if (nextCtrlCWillExit) { + const state = getStatusIndicatorState({ + clipboardMessage, + streamStatus, + nextCtrlCWillExit, + isConnected, + }) + + if (state.kind === 'ctrlC') { return Press Ctrl-C again to exit } - if (clipboardMessage) { - return {clipboardMessage} + if (state.kind === 'clipboard') { + return {state.message} } - const hasStatus = isConnected === false || isActive - - if (!hasStatus) { - return null + if (state.kind === 'connecting') { + return } - if (isConnected === false) { - return + if (state.kind === 'waiting') { + return ( + + ) } - if (isActive) { - if (isWaitingForResponse) { - return ( - - ) - } - return + if (state.kind === 'streaming') { + return ( + + ) } return null } -export const useHasStatus = (params: { - isActive: boolean - clipboardMessage?: string | null - timerStartTime?: number | null - nextCtrlCWillExit: boolean -}): boolean => { - const { isActive, clipboardMessage, timerStartTime, nextCtrlCWillExit } = - params - - const isConnected = useConnectionStatus() - return ( - isConnected === false || - isActive || - Boolean(clipboardMessage) || - Boolean(timerStartTime) || - nextCtrlCWillExit - ) +export const StatusElapsedTime = ({ + streamStatus, + timerStartTime, +}: { + streamStatus: StreamStatus + timerStartTime: number | null +}) => { + const theme = useTheme() + const [elapsedSeconds, setElapsedSeconds] = useState(0) + + const shouldShowTimer = streamStatus !== 'idle' + + useEffect(() => { + if (!timerStartTime || !shouldShowTimer) { + setElapsedSeconds(0) + return + } + + const updateElapsed = () => { + const now = Date.now() + const elapsed = Math.floor((now - timerStartTime) / 1000) + setElapsedSeconds(elapsed) + } + + updateElapsed() + const interval = setInterval(updateElapsed, 1000) + + return () => clearInterval(interval) + }, [timerStartTime, shouldShowTimer]) + + if (!shouldShowTimer || elapsedSeconds === 0) { + return null + } + + return {formatElapsedTime(elapsedSeconds)} } diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index 76c331b8f..dbce0f34c 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -66,7 +66,7 @@ export const SuggestionMenu = ({ paddingRight: 1, paddingTop: 0, paddingBottom: 0, - backgroundColor: isSelected ? theme.agentFocusedBg : theme.background, + backgroundColor: isSelected ? theme.surfaceHover : theme.background, width: '100%', }} > diff --git a/cli/src/components/tools/tool-call-item.tsx b/cli/src/components/tools/tool-call-item.tsx index 337face94..7249a745f 100644 --- a/cli/src/components/tools/tool-call-item.tsx +++ b/cli/src/components/tools/tool-call-item.tsx @@ -194,7 +194,7 @@ export const ToolCallItem = ({ paddingLeft: 0, paddingRight: 0, paddingTop: 0, - paddingBottom: isCollapsed ? 0 : dense ? 0 : 1, + paddingBottom: 0, width: '100%', }} onMouseDown={onToggle} diff --git a/cli/src/hooks/use-chat-input.ts b/cli/src/hooks/use-chat-input.ts index 42cac43f4..a75b60644 100644 --- a/cli/src/hooks/use-chat-input.ts +++ b/cli/src/hooks/use-chat-input.ts @@ -28,16 +28,23 @@ export const useChatInput = ({ }: UseChatInputOptions) => { const hasAutoSubmittedRef = useRef(false) - // Estimate the actual collapsed toggle width as rendered by AgentModeToggle - // Collapsed content is: " < " + LABEL + " " inside a bordered box. - // Full width = contentWidth + 2 (vertical borders). We also include the - // inter-element gap (the right container has paddingLeft: 2). + // Estimate the collapsed toggle width as rendered by AgentModeToggle. + // Collapsed content is "< LABEL" with 1 column of padding on each side and + // a vertical border on each edge. Include the inter-element gap (the right + // container has paddingLeft: 2). const MODE_LABELS = { DEFAULT: 'DEFAULT', MAX: 'MAX', PLAN: 'PLAN' } as const - const collapsedContentWidth = stringWidth(` < ${MODE_LABELS[agentMode]} `) - const collapsedBoxWidth = collapsedContentWidth + 2 // account for │ │ + const collapsedLabelWidth = stringWidth(`< ${MODE_LABELS[agentMode]}`) + const horizontalPadding = 2 // one column padding on each side + const collapsedBoxWidth = collapsedLabelWidth + horizontalPadding + 2 // include │ │ const gapWidth = 2 // paddingLeft on the toggle container const estimatedToggleWidth = collapsedBoxWidth + gapWidth - const inputWidth = Math.max(1, separatorWidth - estimatedToggleWidth) + + // The content box that wraps the input row has paddingLeft/paddingRight = 1 + // (see cli/src/chat.tsx). Subtract those columns so our MultilineInput width + // matches the true drawable area between the borders. + const contentPadding = 2 // 1 left + 1 right padding + const availableContentWidth = Math.max(1, separatorWidth - contentPadding) + const inputWidth = Math.max(1, availableContentWidth - estimatedToggleWidth) const handleBuildFast = useCallback(() => { setAgentMode('DEFAULT') diff --git a/cli/src/hooks/use-connection-status.ts b/cli/src/hooks/use-connection-status.ts new file mode 100644 index 000000000..36465e3f4 --- /dev/null +++ b/cli/src/hooks/use-connection-status.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' + +import { getCodebuffClient } from '../utils/codebuff-client' +import { logger } from '../utils/logger' + +export const useConnectionStatus = () => { + const [isConnected, setIsConnected] = useState(true) + + useEffect(() => { + let isMounted = true + + const checkConnection = async () => { + const client = getCodebuffClient() + if (!client) { + if (isMounted) { + setIsConnected(false) + } + return + } + + try { + const connected = await client.checkConnection() + if (isMounted) { + setIsConnected(connected) + } + } catch (error) { + logger.debug({ error }, 'Connection check failed') + if (isMounted) { + setIsConnected(false) + } + } + } + + checkConnection() + const interval = setInterval(checkConnection, 30000) + + return () => { + isMounted = false + clearInterval(interval) + } + }, []) + + return isConnected +} diff --git a/cli/src/hooks/use-message-queue.ts b/cli/src/hooks/use-message-queue.ts index 27121c945..8e15ace01 100644 --- a/cli/src/hooks/use-message-queue.ts +++ b/cli/src/hooks/use-message-queue.ts @@ -1,15 +1,15 @@ import { useCallback, useEffect, useRef, useState } from 'react' +export type StreamStatus = 'idle' | 'waiting' | 'streaming' + export const useMessageQueue = ( sendMessage: (content: string) => void, isChainInProgressRef: React.MutableRefObject, activeAgentStreamsRef: React.MutableRefObject, ) => { const [queuedMessages, setQueuedMessages] = useState([]) - const [isStreaming, setIsStreaming] = useState(false) + const [streamStatus, setStreamStatus] = useState('idle') const [canProcessQueue, setCanProcessQueue] = useState(true) - const [isWaitingForResponse, setIsWaitingForResponse] = - useState(false) const queuedMessagesRef = useRef([]) const streamTimeoutRef = useRef | null>(null) @@ -31,7 +31,7 @@ export const useMessageQueue = ( } streamMessageIdRef.current = null activeAgentStreamsRef.current = 0 - setIsStreaming(false) + setStreamStatus('idle') }, [activeAgentStreamsRef]) useEffect(() => { @@ -42,7 +42,7 @@ export const useMessageQueue = ( useEffect(() => { if (!canProcessQueue) return - if (isStreaming) return + if (streamStatus !== 'idle') return if (streamMessageIdRef.current) return if (isChainInProgressRef.current) return if (activeAgentStreamsRef.current > 0) return @@ -61,7 +61,7 @@ export const useMessageQueue = ( return () => clearTimeout(timeoutId) }, [ canProcessQueue, - isStreaming, + streamStatus, sendMessage, isChainInProgressRef, activeAgentStreamsRef, @@ -74,27 +74,25 @@ export const useMessageQueue = ( }, []) const startStreaming = useCallback(() => { - setIsStreaming(true) + setStreamStatus('streaming') setCanProcessQueue(false) }, []) const stopStreaming = useCallback(() => { - setIsStreaming(false) + setStreamStatus('idle') setCanProcessQueue(true) }, []) return { queuedMessages, - isStreaming, + streamStatus, canProcessQueue, - isWaitingForResponse, streamMessageIdRef, addToQueue, startStreaming, stopStreaming, - setIsWaitingForResponse, + setStreamStatus, clearStreaming, setCanProcessQueue, - setIsStreaming, } } diff --git a/cli/src/hooks/use-scroll-management.ts b/cli/src/hooks/use-scroll-management.ts index c355a03a7..a7e21a298 100644 --- a/cli/src/hooks/use-scroll-management.ts +++ b/cli/src/hooks/use-scroll-management.ts @@ -2,13 +2,33 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ScrollBoxRenderable } from '@opentui/core' +// Scroll detection threshold - how close to bottom to consider "at bottom" +const SCROLL_NEAR_BOTTOM_THRESHOLD = 1 + +// Animation constants +const ANIMATION_FRAME_INTERVAL_MS = 16 // ~60fps +const DEFAULT_SCROLL_ANIMATION_DURATION_MS = 200 + +// Delay before auto-scrolling after content changes +const AUTO_SCROLL_DELAY_MS = 50 + const easeOutCubic = (t: number): number => { return 1 - Math.pow(1 - t, 3) } +/** + * Manages scroll behavior for the chat scrollbox with smooth animations and auto-scroll. + * + * @param scrollRef - Reference to the scrollbox component + * @param messages - Array of chat messages (triggers auto-scroll on change) + * @param isUserCollapsing - Callback to check if user is actively collapsing/expanding toggles. + * When true, auto-scroll is temporarily suppressed to prevent jarring UX. + * @returns Scroll management functions and state + */ export const useChatScrollbox = ( scrollRef: React.RefObject, messages: any[], + isUserCollapsing: () => boolean, ) => { const autoScrollEnabledRef = useRef(true) const programmaticScrollRef = useRef(false) @@ -23,7 +43,7 @@ export const useChatScrollbox = ( }, []) const animateScrollTo = useCallback( - (targetScroll: number, duration = 200) => { + (targetScroll: number, duration = DEFAULT_SCROLL_ANIMATION_DURATION_MS) => { const scrollbox = scrollRef.current if (!scrollbox) return @@ -32,7 +52,7 @@ export const useChatScrollbox = ( const startScroll = scrollbox.scrollTop const distance = targetScroll - startScroll const startTime = Date.now() - const frameInterval = 16 + const frameInterval = ANIMATION_FRAME_INTERVAL_MS const animate = () => { const elapsed = Date.now() - startTime @@ -78,7 +98,7 @@ export const useChatScrollbox = ( scrollbox.scrollHeight - scrollbox.viewport.height, ) const current = scrollbox.verticalScrollBar.scrollPosition - const isNearBottom = Math.abs(maxScroll - current) <= 1 + const isNearBottom = Math.abs(maxScroll - current) <= SCROLL_NEAR_BOTTOM_THRESHOLD if (programmaticScrollRef.current) { programmaticScrollRef.current = false @@ -111,16 +131,16 @@ export const useChatScrollbox = ( if (scrollbox.scrollTop > maxScroll) { programmaticScrollRef.current = true scrollbox.scrollTop = maxScroll - } else if (autoScrollEnabledRef.current) { + } else if (autoScrollEnabledRef.current && !isUserCollapsing()) { programmaticScrollRef.current = true scrollbox.scrollTop = maxScroll } - }, 50) + }, AUTO_SCROLL_DELAY_MS) return () => clearTimeout(timeoutId) } return undefined - }, [messages, scrollToLatest, scrollRef]) + }, [messages, scrollToLatest, scrollRef, isUserCollapsing]) useEffect(() => { return () => { diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 6a5af54c8..616489c7d 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -16,6 +16,7 @@ import type { SendMessageFn } from '../types/contracts/send-message' import type { ParamsOf } from '../types/function-params' import type { SetElement } from '../types/utils' import type { AgentMode } from '../utils/constants' +import type { StreamStatus } from './use-message-queue' import type { AgentDefinition, ToolName } from '@codebuff/sdk' import type { SetStateAction } from 'react' const hiddenToolNames = new Set([ @@ -79,6 +80,31 @@ const scrubPlanTagsInBlocks = (blocks: ContentBlock[]): ContentBlock[] => { .filter((b) => b.type !== 'text' || b.content.trim() !== '') } +/** + * Auto-collapse thinking blocks to reduce UI clutter. + * Tracks which thinking blocks have been collapsed to avoid duplicate collapses. + * + * @param messageId - ID of the message containing the thinking block + * @param agentId - Optional agent ID for nested agent thinking blocks + * @param autoCollapsedThinkingIdsRef - Ref tracking which thinking IDs have been auto-collapsed + * @param setCollapsedAgents - State setter for collapsed agents + */ +const autoCollapseThinkingBlock = ( + messageId: string, + agentId: string | undefined, + autoCollapsedThinkingIdsRef: React.MutableRefObject>, + setCollapsedAgents: React.Dispatch>>, +) => { + const thinkingId = agentId + ? `${messageId}-agent-${agentId}-thinking-0` + : `${messageId}-thinking-0` + + if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) { + autoCollapsedThinkingIdsRef.current.add(thinkingId) + setCollapsedAgents((prev) => new Set(prev).add(thinkingId)) + } +} + export type SendMessageTimerEvent = | { type: 'start' @@ -185,10 +211,9 @@ interface UseSendMessageOptions { isChainInProgressRef: React.MutableRefObject setActiveSubagents: React.Dispatch>> setIsChainInProgress: (value: boolean) => void - setIsWaitingForResponse: (waiting: boolean) => void + setStreamStatus: (status: StreamStatus) => void startStreaming: () => void stopStreaming: () => void - setIsStreaming: (streaming: boolean) => void setCanProcessQueue: (can: boolean) => void abortControllerRef: React.MutableRefObject agentId?: string @@ -219,10 +244,9 @@ export const useSendMessage = ({ isChainInProgressRef, setActiveSubagents, setIsChainInProgress, - setIsWaitingForResponse, + setStreamStatus, startStreaming, stopStreaming, - setIsStreaming, setCanProcessQueue, abortControllerRef, agentId, @@ -734,9 +758,8 @@ export const useSendMessage = ({ } } - setIsWaitingForResponse(true) + setStreamStatus('waiting') applyMessageUpdate((prev) => [...prev, aiMessage]) - setIsStreaming(true) setCanProcessQueue(false) updateChainInProgress(true) let hasReceivedContent = false @@ -745,10 +768,9 @@ export const useSendMessage = ({ const abortController = new AbortController() abortControllerRef.current = abortController abortController.signal.addEventListener('abort', () => { - setIsStreaming(false) + setStreamStatus('idle') setCanProcessQueue(true) updateChainInProgress(false) - setIsWaitingForResponse(false) timerController.stop('aborted') applyMessageUpdate((prev) => @@ -820,7 +842,7 @@ export const useSendMessage = ({ : { type: 'reasoning', text: event.chunk } if (!hasReceivedContent) { hasReceivedContent = true - setIsWaitingForResponse(false) + setStreamStatus('streaming') } if (!eventObj.text) { @@ -834,11 +856,12 @@ export const useSendMessage = ({ // Auto-collapse thinking blocks by default (only once per thinking block) if (eventObj.type === 'reasoning') { - const thinkingId = `${aiMessageId}-thinking-0` - if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) { - autoCollapsedThinkingIdsRef.current.add(thinkingId) - setCollapsedAgents((prev) => new Set(prev).add(thinkingId)) - } + autoCollapseThinkingBlock( + aiMessageId, + undefined, + autoCollapsedThinkingIdsRef, + setCollapsedAgents, + ) } rootStreamSeenRef.current = true @@ -881,10 +904,10 @@ export const useSendMessage = ({ // Track if main agent (no agentId) started streaming if (!hasReceivedContent && !event.agentId) { hasReceivedContent = true - setIsWaitingForResponse(false) + setStreamStatus('streaming') } else if (!hasReceivedContent) { hasReceivedContent = true - setIsWaitingForResponse(false) + setStreamStatus('streaming') } if (event.agentId) { @@ -907,11 +930,12 @@ export const useSendMessage = ({ // Auto-collapse thinking blocks for subagents on first content if (previous.length === 0) { - const thinkingId = `${aiMessageId}-agent-${event.agentId}-thinking-0` - if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) { - autoCollapsedThinkingIdsRef.current.add(thinkingId) - setCollapsedAgents((prev) => new Set(prev).add(thinkingId)) - } + autoCollapseThinkingBlock( + aiMessageId, + event.agentId, + autoCollapsedThinkingIdsRef, + setCollapsedAgents, + ) } updateAgentContent(event.agentId, { @@ -920,14 +944,7 @@ export const useSendMessage = ({ }) } else { if (rootStreamSeenRef.current) { - // Disabled noisy log - // logger.info( - // { - // textPreview: text.slice(0, 100), - // textLength: text.length, - // }, - // 'Skipping root text event (stream already handled)', - // ) + // Skip redundant root text events when stream chunks already handled return } const previous = rootStreamBufferRef.current ?? '' @@ -1539,10 +1556,9 @@ export const useSendMessage = ({ return } - setIsStreaming(false) + setStreamStatus('idle') setCanProcessQueue(true) updateChainInProgress(false) - setIsWaitingForResponse(false) const timerResult = timerController.stop('success') if (agentMode === 'PLAN') { @@ -1576,10 +1592,9 @@ export const useSendMessage = ({ { error: getErrorObject(error) }, 'SDK client.run() failed', ) - setIsStreaming(false) + setStreamStatus('idle') setCanProcessQueue(true) updateChainInProgress(false) - setIsWaitingForResponse(false) timerController.stop('error') const errorMessage = @@ -1620,10 +1635,9 @@ export const useSendMessage = ({ userOpenedAgents, activeSubagentsRef, isChainInProgressRef, - setIsWaitingForResponse, + setStreamStatus, startStreaming, stopStreaming, - setIsStreaming, setCanProcessQueue, abortControllerRef, updateChainInProgress, diff --git a/cli/src/utils/__tests__/format-elapsed-time.test.ts b/cli/src/utils/__tests__/format-elapsed-time.test.ts new file mode 100644 index 000000000..108abff90 --- /dev/null +++ b/cli/src/utils/__tests__/format-elapsed-time.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect } from 'bun:test' + +import { formatElapsedTime } from '../format-elapsed-time' + +describe('formatElapsedTime', () => { + describe('seconds format (< 60s)', () => { + test('formats 0 seconds', () => { + expect(formatElapsedTime(0)).toBe('0s') + }) + + test('formats single digit seconds', () => { + expect(formatElapsedTime(5)).toBe('5s') + expect(formatElapsedTime(9)).toBe('9s') + }) + + test('formats double digit seconds', () => { + expect(formatElapsedTime(30)).toBe('30s') + expect(formatElapsedTime(45)).toBe('45s') + expect(formatElapsedTime(59)).toBe('59s') + }) + }) + + describe('minutes format (60s - 3599s)', () => { + test('formats exactly 1 minute', () => { + expect(formatElapsedTime(60)).toBe('1m 0s') + }) + + test('formats minutes with remaining seconds (floors down)', () => { + expect(formatElapsedTime(90)).toBe('1m 30s') + expect(formatElapsedTime(119)).toBe('1m 59s') + expect(formatElapsedTime(125)).toBe('2m 5s') + }) + + test('formats double digit minutes', () => { + expect(formatElapsedTime(600)).toBe('10m 0s') + expect(formatElapsedTime(1800)).toBe('30m 0s') + expect(formatElapsedTime(3540)).toBe('59m 0s') + }) + + test('formats just under 1 hour', () => { + expect(formatElapsedTime(3599)).toBe('59m 59s') + }) + }) + + describe('hours format (>= 3600s)', () => { + test('formats exactly 1 hour', () => { + expect(formatElapsedTime(3600)).toBe('1h 0m') + }) + + test('formats hours with remaining time (floors down)', () => { + expect(formatElapsedTime(3661)).toBe('1h 1m') + expect(formatElapsedTime(5400)).toBe('1h 30m') + expect(formatElapsedTime(7199)).toBe('1h 59m') + expect(formatElapsedTime(7200)).toBe('2h 0m') + }) + + test('formats multiple hours', () => { + expect(formatElapsedTime(10800)).toBe('3h 0m') + expect(formatElapsedTime(36000)).toBe('10h 0m') + expect(formatElapsedTime(86400)).toBe('24h 0m') + }) + }) + + describe('edge cases', () => { + test('handles very large numbers', () => { + expect(formatElapsedTime(999999)).toBe('277h 46m') + }) + + test('handles negative numbers gracefully (should not occur in practice)', () => { + // Negative numbers shouldn't happen, but verify behavior + const result = formatElapsedTime(-10) + // Will return "-10s" - negative formatting is technically wrong but harmless + expect(result).toBe('-10s') + }) + + test('handles boundary between seconds and minutes', () => { + expect(formatElapsedTime(59)).toBe('59s') + expect(formatElapsedTime(60)).toBe('1m 0s') + expect(formatElapsedTime(61)).toBe('1m 1s') + }) + + test('handles boundary between minutes and hours', () => { + expect(formatElapsedTime(3599)).toBe('59m 59s') + expect(formatElapsedTime(3600)).toBe('1h 0m') + expect(formatElapsedTime(3601)).toBe('1h 0m') + }) + }) + + describe('real-world scenarios', () => { + test('formats typical LLM response times', () => { + expect(formatElapsedTime(3)).toBe('3s') // Quick response + expect(formatElapsedTime(15)).toBe('15s') // Average response + expect(formatElapsedTime(45)).toBe('45s') // Longer response + expect(formatElapsedTime(120)).toBe('2m 0s') // Very long response + }) + + test('formats extended task durations', () => { + expect(formatElapsedTime(180)).toBe('3m 0s') // 3 minute task + expect(formatElapsedTime(900)).toBe('15m 0s') // 15 minute task + expect(formatElapsedTime(3600)).toBe('1h 0m') // 1 hour task + }) + }) +}) diff --git a/cli/src/utils/format-elapsed-time.ts b/cli/src/utils/format-elapsed-time.ts new file mode 100644 index 000000000..b9fc2fa05 --- /dev/null +++ b/cli/src/utils/format-elapsed-time.ts @@ -0,0 +1,31 @@ +/** + * Format elapsed seconds into a human-readable string. + * + * @param elapsedSeconds - Number of seconds elapsed (should be non-negative) + * @returns Formatted time string + * + * @example + * formatElapsedTime(30) // "30s" + * formatElapsedTime(90) // "1m 30s" + * formatElapsedTime(3700) // "1h 1m" + * + * Format rules: + * - Under 60 seconds: "Xs" (e.g., "45s") + * - 60-3599 seconds (1-59 minutes): "Xm Ys" (e.g., "12m 5s") + * - 3600+ seconds (1+ hours): "Xh Ym" (e.g., "2h 15m") + */ +export const formatElapsedTime = (elapsedSeconds: number): string => { + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s` + } + + if (elapsedSeconds < 3600) { + const minutes = Math.floor(elapsedSeconds / 60) + const seconds = elapsedSeconds % 60 + return `${minutes}m ${seconds}s` + } + + const hours = Math.floor(elapsedSeconds / 3600) + const minutes = Math.floor((elapsedSeconds % 3600) / 60) + return `${hours}h ${minutes}m` +}