@@ -23,6 +23,7 @@ import { Separator } from './components/separator'
2323import { StatusIndicator , useHasStatus } from './components/status-indicator'
2424import { SuggestionMenu } from './components/suggestion-menu'
2525import { SLASH_COMMANDS } from './data/slash-commands'
26+ import { useAgentValidation } from './hooks/use-agent-validation'
2627import { useAuthQuery , useLogoutMutation } from './hooks/use-auth-query'
2728import { useClipboard } from './hooks/use-clipboard'
2829import { useInputHistory } from './hooks/use-input-history'
@@ -39,11 +40,19 @@ import { getUserCredentials } from './utils/auth'
3940import { LOGO } from './login/constants'
4041import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
4142import { formatQueuedPreview } from './utils/helpers'
42- import { loadLocalAgents } from './utils/local-agent-registry'
43+ import {
44+ loadLocalAgents ,
45+ type LocalAgentInfo ,
46+ } from './utils/local-agent-registry'
4347import { logger } from './utils/logger'
4448import { buildMessageTree } from './utils/message-tree-utils'
45- import { chatThemes , createMarkdownPalette } from './utils/theme-system'
49+ import {
50+ chatThemes ,
51+ createMarkdownPalette ,
52+ type ChatTheme ,
53+ } from './utils/theme-system'
4654import { openFileAtPath } from './utils/open-file'
55+ import { formatValidationError } from './utils/validation-error-formatting'
4756
4857import type { User } from './utils/auth'
4958import type { ToolName } from '@codebuff/sdk'
@@ -155,6 +164,10 @@ export const App = ({
155164 const theme = chatThemes [ themeName ]
156165 const markdownPalette = useMemo ( ( ) => createMarkdownPalette ( theme ) , [ theme ] )
157166
167+ // Set up agent validation (manual trigger)
168+ const { validationErrors : liveValidationErrors , validate : validateAgents } =
169+ useAgentValidation ( validationErrors )
170+
158171 const [ exitWarning , setExitWarning ] = useState < string | null > ( null )
159172 const exitArmedRef = useRef ( false )
160173 const exitWarningTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > (
@@ -245,127 +258,6 @@ export const App = ({
245258 'Codebuff can read and write files in this repository, and run terminal commands to help you build.' ,
246259 } )
247260
248- // Add validation errors if any exist
249- if ( validationErrors . length > 0 ) {
250- const errorCount = validationErrors . length
251- const errorHeader =
252- errorCount === 1
253- ? '**⚠️ 1 agent has validation issues**'
254- : `**⚠️ ${ errorCount } agents have validation issues**`
255-
256- // Add header
257- blocks . push ( {
258- type : 'text' ,
259- content : errorHeader ,
260- marginTop : 1 ,
261- } )
262-
263- // Add each error as a separate, nicely formatted block
264- const agentInfoById = new Map (
265- loadedAgentsData . agents . map ( ( agent ) => [ agent . id , agent ] ) ,
266- )
267-
268- const normalizeRelativePath = ( filePath : string ) : string => {
269- const relativeToAgentsDir = path . relative (
270- loadedAgentsData . agentsDir ,
271- filePath ,
272- )
273- const normalized = relativeToAgentsDir . replace ( / \\ / g, '/' )
274- return `.agents/${ normalized } `
275- }
276-
277- validationErrors . forEach ( ( error , errorIndex ) => {
278- // Extract just the key error message, removing verbose schema details
279- let message = error . message
280- . replace ( / A g e n t " [ ^ " ] + " \s * (?: \( [ ^ ) ] + \) ) ? \s * : \s * / , '' )
281- . replace ( / S c h e m a v a l i d a t i o n f a i l e d : \s * / i, '' )
282- . trim ( )
283-
284- // If message starts with JSON array, extract first error
285- if ( message . startsWith ( '[' ) ) {
286- try {
287- const errors = JSON . parse ( message )
288- if ( Array . isArray ( errors ) && errors . length > 0 ) {
289- const firstError = errors [ 0 ]
290- // Get the field path and message
291- const field = firstError . path ?. join ( '.' ) || 'field'
292- message = `${ field } : ${ firstError . message } `
293- }
294- } catch {
295- // Keep original message if parsing fails
296- }
297- }
298-
299- // Clean up common validation messages to be more user-friendly
300- message = message
301- . replace ( / I n v a l i d i n p u t : e x p e c t e d ( \w + ) , r e c e i v e d ( \w + ) / i, 'Expected $1, got $2' )
302- . replace ( / A g e n t I D m u s t c o n t a i n o n l y l o w e r c a s e l e t t e r s , n u m b e r s , a n d h y p h e n s / i, 'ID must be lowercase with hyphens only' )
303- . replace ( / C a n n o t s p e c i f y b o t h ( \w + ) a n d ( \w + ) \. .* / i, 'Cannot use both $1 and $2' )
304-
305- // Take first line only and limit length
306- message = message . split ( '\n' ) [ 0 ]
307- if ( message . length > 80 ) {
308- message = message . substring ( 0 , 77 ) + '...'
309- }
310-
311- const agentId = error . id . replace ( / _ \d + $ / , '' )
312- const agentInfo = agentInfoById . get ( agentId )
313- const relativePath = agentInfo
314- ? normalizeRelativePath ( agentInfo . filePath )
315- : null
316-
317- const fieldMatch = message . match ( / ^ ( [ ^ : ] + ) : \s * ( .+ ) $ / )
318- const fieldName = fieldMatch ? fieldMatch [ 1 ] : null
319- const errorBody = fieldMatch ? fieldMatch [ 2 ] : message
320-
321- blocks . push ( {
322- type : 'html' ,
323- marginTop : errorIndex === 0 ? 0 : 0 ,
324- render : ( { textColor } ) => (
325- < box style = { { flexDirection : 'row' , gap : 1 , alignItems : 'center' } } >
326- < text wrap style = { { fg : textColor } } >
327- < span attributes = { TextAttributes . BOLD } > { agentId } </ span >
328- </ text >
329- { relativePath ? (
330- < TerminalLink
331- text = { `(${ relativePath } )` }
332- containerStyle = { {
333- width : 'auto' ,
334- flexDirection : 'row' ,
335- alignItems : 'center' ,
336- } }
337- formatLines = { ( text ) => [ text ] }
338- underlineOnHover
339- onActivate = { ( ) => openFileAtPath ( agentInfo . filePath ) }
340- />
341- ) : null }
342- </ box >
343- ) ,
344- } )
345-
346- blocks . push ( {
347- type : 'html' ,
348- marginBottom : 0 ,
349- render : ( { textColor } ) => (
350- < text wrap style = { { fg : textColor , marginLeft : 2 } } >
351- { fieldName ? (
352- < >
353- < span attributes = { TextAttributes . ITALIC } >
354- { `${ fieldName } :` }
355- </ span > { ' ' }
356- { errorBody }
357- </ >
358- ) : (
359- errorBody
360- ) }
361- </ text >
362- ) ,
363- } )
364- } )
365-
366- // No closing instruction to keep layout concise
367- }
368-
369261 blocks . push ( {
370262 type : 'agent-list' ,
371263 id : agentListId ,
@@ -517,6 +409,18 @@ export const App = ({
517409
518410 const { clipboardMessage } = useClipboard ( )
519411
412+ // Track main agent streaming start time for elapsed time display
413+ const [ mainAgentStreamStartTime , setMainAgentStreamStartTime ] =
414+ useState < number | null > ( null )
415+
416+ // Debug log when stream start time changes
417+ useEffect ( ( ) => {
418+ logger . debug (
419+ { streamStartTime : mainAgentStreamStartTime } ,
420+ '[STREAM TIMER] mainAgentStreamStartTime state changed' ,
421+ )
422+ } , [ mainAgentStreamStartTime ] )
423+
520424 const agentRefsMap = useRef < Map < string , any > > ( new Map ( ) )
521425 const hasAutoSubmittedRef = useRef ( false )
522426 const activeSubagentsRef = useRef < Set < string > > ( activeSubagents )
@@ -870,6 +774,8 @@ export const App = ({
870774 setCanProcessQueue,
871775 abortControllerRef,
872776 agentId,
777+ onBeforeMessageSend : validateAgents ,
778+ setMainAgentStreamStartTime,
873779 } )
874780
875781 sendMessageRef . current = sendMessage
@@ -890,7 +796,14 @@ export const App = ({
890796 return undefined
891797 } , [ initialPrompt , agentMode ] )
892798
893- const hasStatus = useHasStatus ( isWaitingForResponse , clipboardMessage )
799+ // Show thinking indicator even after waiting ends if we're still streaming
800+ const showThinking = isStreaming && ! isWaitingForResponse
801+ const hasStatus = useHasStatus (
802+ isWaitingForResponse ,
803+ clipboardMessage ,
804+ showThinking ,
805+ mainAgentStreamStartTime ,
806+ )
894807
895808 const handleSubmit = useCallback ( ( ) => {
896809 const trimmed = inputValue . trim ( )
@@ -1040,14 +953,136 @@ export const App = ({
1040953 const shouldShowStatusLine = Boolean (
1041954 exitWarning || hasStatus || shouldShowQueuePreview ,
1042955 )
956+
957+ // Debug log status line conditions
958+ useEffect ( ( ) => {
959+ logger . debug (
960+ {
961+ shouldShowStatusLine,
962+ hasStatus,
963+ isWaitingForResponse,
964+ showThinking,
965+ mainAgentStreamStartTime,
966+ } ,
967+ '[STREAM TIMER] Status line conditions' ,
968+ )
969+ } , [
970+ shouldShowStatusLine ,
971+ hasStatus ,
972+ isWaitingForResponse ,
973+ showThinking ,
974+ mainAgentStreamStartTime ,
975+ ] )
976+
1043977 const statusIndicatorNode = (
1044978 < StatusIndicator
1045979 isProcessing = { isWaitingForResponse }
1046980 theme = { theme }
1047981 clipboardMessage = { clipboardMessage }
982+ showThinking = { showThinking }
983+ streamStartTime = { mainAgentStreamStartTime }
1048984 />
1049985 )
1050986
987+ // Render validation banner
988+ const renderValidationBanner = ( ) => {
989+ if ( liveValidationErrors . length === 0 ) {
990+ return null
991+ }
992+
993+ const MAX_VISIBLE_ERRORS = 5
994+ const errorCount = liveValidationErrors . length
995+ const visibleErrors = liveValidationErrors . slice ( 0 , MAX_VISIBLE_ERRORS )
996+ const hasMoreErrors = errorCount > MAX_VISIBLE_ERRORS
997+
998+ // Helper to normalize relative path
999+ const normalizeRelativePath = ( filePath : string ) : string => {
1000+ if ( ! loadedAgentsData ) return filePath
1001+ const relativeToAgentsDir = path . relative (
1002+ loadedAgentsData . agentsDir ,
1003+ filePath ,
1004+ )
1005+ const normalized = relativeToAgentsDir . replace ( / \\ / g, '/' )
1006+ return `.agents/${ normalized } `
1007+ }
1008+
1009+ // Get agent info by ID
1010+ const agentInfoById = new Map < string , LocalAgentInfo > (
1011+ ( loadedAgentsData ?. agents . map ( ( agent ) => [
1012+ agent . id ,
1013+ agent as LocalAgentInfo ,
1014+ ] ) || [ ] ) as [ string , LocalAgentInfo ] [ ] ,
1015+ )
1016+
1017+ return (
1018+ < box
1019+ style = { {
1020+ flexDirection : 'column' ,
1021+ paddingLeft : 1 ,
1022+ paddingRight : 1 ,
1023+ paddingTop : 1 ,
1024+ paddingBottom : 1 ,
1025+ backgroundColor : theme . panelBg ,
1026+ border : true ,
1027+ borderStyle : 'single' ,
1028+ borderColor : '#FFA500' ,
1029+ } }
1030+ >
1031+ { /* Header */ }
1032+ < box
1033+ style = { {
1034+ flexDirection : 'row' ,
1035+ alignItems : 'center' ,
1036+ paddingBottom : 0 ,
1037+ } }
1038+ >
1039+ < text wrap = { false } style = { { fg : theme . messageAiText } } >
1040+ { `⚠️ ${ errorCount === 1 ? '1 agent has validation issues' : `${ errorCount } agents have validation issues` } ` }
1041+ { hasMoreErrors &&
1042+ ` (showing ${ MAX_VISIBLE_ERRORS } of ${ errorCount } )` }
1043+ </ text >
1044+ </ box >
1045+
1046+ { /* Error list - build as single text with newlines */ }
1047+ < text wrap style = { { fg : theme . messageAiText } } >
1048+ { visibleErrors . map ( ( error , index ) => {
1049+ const agentId = error . id . replace ( / _ \d + $ / , '' )
1050+ const agentInfo = agentInfoById . get ( agentId )
1051+ const relativePath = agentInfo
1052+ ? normalizeRelativePath ( agentInfo . filePath )
1053+ : null
1054+
1055+ const { fieldName, message } = formatValidationError ( error . message )
1056+ const errorMsg = fieldName ? `${ fieldName } : ${ message } ` : message
1057+ const truncatedMsg = errorMsg . length > 68 ? errorMsg . substring ( 0 , 65 ) + '...' : errorMsg
1058+
1059+ let output = index === 0 ? '\n' : '\n\n'
1060+ output += agentId
1061+ if ( relativePath ) {
1062+ output += ` (${ relativePath } )`
1063+ }
1064+ output += '\n ' + truncatedMsg
1065+ return output
1066+ } ) . join ( '' ) }
1067+ </ text >
1068+
1069+ { /* Show count of additional errors */ }
1070+ { hasMoreErrors && (
1071+ < box
1072+ style = { {
1073+ flexDirection : 'row' ,
1074+ paddingTop : 0 ,
1075+ } }
1076+ >
1077+ < text wrap = { false } style = { { fg : theme . statusSecondary } } >
1078+ { `... and ${ errorCount - MAX_VISIBLE_ERRORS } more` }
1079+ </ text >
1080+ </ box >
1081+ ) }
1082+ </ box >
1083+ )
1084+ }
1085+
10511086 return (
10521087 < box
10531088 style = { {
@@ -1058,6 +1093,9 @@ export const App = ({
10581093 flexGrow : 1 ,
10591094 } }
10601095 >
1096+ { /* Validation banner at the top */ }
1097+ { renderValidationBanner ( ) }
1098+
10611099 < box
10621100 style = { {
10631101 flexDirection : 'column' ,
0 commit comments