@@ -21,6 +21,8 @@ import { resolveContextUsageColor } from './InboxListView';
2121import { formatRelativeTime } from '../../utils/formatters' ;
2222import { MarkdownRenderer } from '../MarkdownRenderer' ;
2323import { generateTerminalProseStyles } from '../../utils/markdownConfig' ;
24+ import { slashCommands } from '../../slashCommands' ;
25+ import { fuzzyMatchWithScore } from '../../utils/search' ;
2426
2527/* POLISH-04 Token Audit (@architect)
2628 * Line 166: bgSidebar in user bubble color-mix — CORRECT (chrome blend for user messages)
@@ -430,7 +432,7 @@ function FocusSidebar({
430432 </ div >
431433 ) }
432434 { /* Item list */ }
433- < div role = "listbox " aria-label = "Inbox items" className = "flex-1 overflow-y-auto py-1" >
435+ < div role = "list " aria-label = "Inbox items" className = "flex-1 overflow-y-auto py-1" >
434436 { ( ( ) => {
435437 let activeGroup : string | null = null ;
436438 return rows . map ( ( row , rowIdx ) => {
@@ -505,8 +507,8 @@ function FocusSidebar({
505507 key = { `${ itm . sessionId } -${ itm . tabId } ` }
506508 ref = { isCurrent ? currentRowRef : undefined }
507509 tabIndex = { 0 }
508- role = "option "
509- aria-selected = { isCurrent }
510+ role = "listitem "
511+ aria-current = { isCurrent ? 'true' : undefined }
510512 onClick = { ( ) => onNavigateItem ( idx ) }
511513 onKeyDown = { ( e ) => {
512514 if ( e . key === 'Enter' || e . key === ' ' ) {
@@ -733,6 +735,31 @@ export default function FocusModeView({
733735 const [ replyText , setReplyText ] = useState ( '' ) ;
734736 const replyInputRef = useRef < HTMLTextAreaElement > ( null ) ;
735737
738+ // ---- Slash command autocomplete state ----
739+ const [ slashCommandOpen , setSlashCommandOpen ] = useState ( false ) ;
740+ const [ selectedSlashCommandIndex , setSelectedSlashCommandIndex ] = useState ( 0 ) ;
741+
742+ const replyTextLower = useMemo ( ( ) => replyText . toLowerCase ( ) , [ replyText ] ) ;
743+ const filteredSlashCommands = useMemo ( ( ) => {
744+ return slashCommands
745+ . filter ( ( cmd ) => ! cmd . terminalOnly ) // Focus mode is always AI mode
746+ . map ( ( cmd ) => {
747+ const result = fuzzyMatchWithScore ( cmd . command , replyTextLower ) ;
748+ if ( ! result . matches ) return null ;
749+ return { cmd, score : result . score } ;
750+ } )
751+ . filter (
752+ ( item ) : item is { cmd : ( typeof slashCommands ) [ number ] ; score : number } => item !== null
753+ )
754+ . sort ( ( a , b ) => b . score - a . score )
755+ . map ( ( item ) => item . cmd ) ;
756+ } , [ replyTextLower ] ) ;
757+
758+ const safeSlashIndex = Math . min (
759+ Math . max ( 0 , selectedSlashCommandIndex ) ,
760+ Math . max ( 0 , filteredSlashCommands . length - 1 )
761+ ) ;
762+
736763 // Auto-focus reply input when entering focus mode or switching items.
737764 useEffect ( ( ) => {
738765 const rafId = requestAnimationFrame ( ( ) => {
@@ -741,30 +768,27 @@ export default function FocusModeView({
741768 return ( ) => cancelAnimationFrame ( rafId ) ;
742769 } , [ item . sessionId , item . tabId ] ) ;
743770
744- // Reset reply text and textarea height when item changes (prev/next navigation)
771+ // Reset reply text, slash command state, and textarea height when item changes (prev/next navigation)
745772 useEffect ( ( ) => {
746773 setReplyText ( '' ) ;
774+ setSlashCommandOpen ( false ) ;
747775 if ( replyInputRef . current ) {
748776 replyInputRef . current . style . height = 'auto' ;
749777 }
750778 } , [ item . sessionId , item . tabId ] ) ;
751779
752780 const handleQuickReply = useCallback ( ( ) => {
753781 const text = replyText . trim ( ) ;
754- if ( ! text ) return ;
755- if ( onQuickReply ) {
756- onQuickReply ( item . sessionId , item . tabId , text ) ;
757- }
782+ if ( ! text || ! onQuickReply ) return ;
783+ onQuickReply ( item . sessionId , item . tabId , text ) ;
758784 onMarkAsRead ?.( item . sessionId , item . tabId ) ;
759785 setReplyText ( '' ) ;
760786 } , [ replyText , item , onQuickReply , onMarkAsRead ] ) ;
761787
762788 const handleOpenAndReply = useCallback ( ( ) => {
763789 const text = replyText . trim ( ) ;
764- if ( ! text ) return ;
765- if ( onOpenAndReply ) {
766- onOpenAndReply ( item . sessionId , item . tabId , text ) ;
767- }
790+ if ( ! text || ! onOpenAndReply ) return ;
791+ onOpenAndReply ( item . sessionId , item . tabId , text ) ;
768792 onMarkAsRead ?.( item . sessionId , item . tabId ) ;
769793 } , [ replyText , item , onOpenAndReply , onMarkAsRead ] ) ;
770794
@@ -1033,14 +1057,97 @@ export default function FocusModeView({
10331057
10341058 { /* Reply input bar */ }
10351059 < div
1036- className = "flex items-center gap-2 px-4 py-3 border-t"
1060+ className = "relative flex items-center gap-2 px-4 py-3 border-t"
10371061 style = { { borderColor : theme . colors . border } }
10381062 >
1063+ { /* Slash Command Autocomplete Dropdown */ }
1064+ { slashCommandOpen && filteredSlashCommands . length > 0 && (
1065+ < div
1066+ className = "absolute bottom-full left-0 right-0 mb-1 mx-4 border rounded-lg shadow-2xl overflow-hidden z-50"
1067+ style = { {
1068+ backgroundColor : theme . colors . bgSidebar ,
1069+ borderColor : theme . colors . border ,
1070+ } }
1071+ >
1072+ < div
1073+ className = "overflow-y-auto max-h-48 scrollbar-thin"
1074+ style = { { overscrollBehavior : 'contain' } }
1075+ >
1076+ { filteredSlashCommands . map ( ( cmd , idx ) => (
1077+ < button
1078+ type = "button"
1079+ key = { cmd . command }
1080+ className = { `w-full px-4 py-2.5 text-left transition-colors ${
1081+ idx === safeSlashIndex ? 'font-semibold' : ''
1082+ } `}
1083+ style = { {
1084+ backgroundColor :
1085+ idx === safeSlashIndex ? theme . colors . accent : 'transparent' ,
1086+ color : idx === safeSlashIndex ? theme . colors . bgMain : theme . colors . textMain ,
1087+ } }
1088+ onMouseDown = { ( e ) => {
1089+ // Use mouseDown instead of click to fire before textarea blur
1090+ e . preventDefault ( ) ;
1091+ setReplyText ( cmd . command ) ;
1092+ setSlashCommandOpen ( false ) ;
1093+ replyInputRef . current ?. focus ( ) ;
1094+ } }
1095+ onMouseEnter = { ( ) => setSelectedSlashCommandIndex ( idx ) }
1096+ >
1097+ < div className = "font-mono text-sm" > { cmd . command } </ div >
1098+ < div className = "text-xs opacity-70 mt-0.5" > { cmd . description } </ div >
1099+ </ button >
1100+ ) ) }
1101+ </ div >
1102+ </ div >
1103+ ) }
10391104 < textarea
10401105 ref = { replyInputRef }
10411106 value = { replyText }
1042- onChange = { ( e ) => setReplyText ( e . target . value ) }
1107+ onChange = { ( e ) => {
1108+ const value = e . target . value ;
1109+ setReplyText ( value ) ;
1110+ // Detect slash command trigger
1111+ if ( value . startsWith ( '/' ) && ! value . includes ( ' ' ) && ! value . includes ( '\n' ) ) {
1112+ if ( ! slashCommandOpen ) setSelectedSlashCommandIndex ( 0 ) ;
1113+ setSlashCommandOpen ( true ) ;
1114+ } else {
1115+ setSlashCommandOpen ( false ) ;
1116+ }
1117+ } }
1118+ disabled = { ! sessionExists }
10431119 onKeyDown = { ( e ) => {
1120+ if ( ! sessionExists ) return ;
1121+ // Slash command navigation
1122+ if ( slashCommandOpen && filteredSlashCommands . length > 0 ) {
1123+ if ( e . key === 'ArrowDown' ) {
1124+ e . preventDefault ( ) ;
1125+ e . stopPropagation ( ) ;
1126+ setSelectedSlashCommandIndex (
1127+ Math . min ( safeSlashIndex + 1 , filteredSlashCommands . length - 1 )
1128+ ) ;
1129+ return ;
1130+ }
1131+ if ( e . key === 'ArrowUp' ) {
1132+ e . preventDefault ( ) ;
1133+ e . stopPropagation ( ) ;
1134+ setSelectedSlashCommandIndex ( Math . max ( safeSlashIndex - 1 , 0 ) ) ;
1135+ return ;
1136+ }
1137+ if ( e . key === 'Enter' && ! e . shiftKey && ! e . metaKey ) {
1138+ e . preventDefault ( ) ;
1139+ e . stopPropagation ( ) ;
1140+ setReplyText ( filteredSlashCommands [ safeSlashIndex ] . command ) ;
1141+ setSlashCommandOpen ( false ) ;
1142+ return ;
1143+ }
1144+ if ( e . key === 'Escape' ) {
1145+ e . preventDefault ( ) ;
1146+ e . stopPropagation ( ) ;
1147+ setSlashCommandOpen ( false ) ;
1148+ return ;
1149+ }
1150+ }
10441151 if ( e . key === 'Enter' ) {
10451152 if ( enterToSendAI ) {
10461153 // Enter sends, Shift+Enter = Open & Reply
@@ -1067,7 +1174,7 @@ export default function FocusModeView({
10671174 // CRITICAL: Prevent focus-mode keyboard shortcuts from firing while typing
10681175 e . stopPropagation ( ) ;
10691176 } }
1070- placeholder = " Reply to agent..."
1177+ placeholder = { sessionExists ? ' Reply to agent...' : 'Session unavailable' }
10711178 rows = { 1 }
10721179 aria-label = "Reply to agent"
10731180 className = "flex-1 resize-none rounded-lg px-3 py-2 text-sm outline-none"
@@ -1088,7 +1195,7 @@ export default function FocusModeView({
10881195 { /* Quick Reply button (primary) */ }
10891196 < button
10901197 onClick = { handleQuickReply }
1091- disabled = { ! replyText . trim ( ) }
1198+ disabled = { ! sessionExists || ! replyText . trim ( ) }
10921199 className = "p-2 rounded-lg transition-colors flex-shrink-0"
10931200 style = { {
10941201 backgroundColor : replyText . trim ( )
@@ -1104,7 +1211,7 @@ export default function FocusModeView({
11041211 { /* Open & Reply button (secondary) */ }
11051212 < button
11061213 onClick = { handleOpenAndReply }
1107- disabled = { ! replyText . trim ( ) }
1214+ disabled = { ! sessionExists || ! replyText . trim ( ) }
11081215 className = "p-1.5 rounded-lg transition-colors flex-shrink-0 text-xs"
11091216 style = { {
11101217 border : `1px solid ${ theme . colors . border } ` ,
0 commit comments