@@ -8,10 +8,10 @@ import {
88} from './dom.js' ;
99import {
1010 escapeHtml , escapeAttr , formatFileSize , scrollToBottom ,
11- showCancel , toggleShortcuts , SCROLL_THRESHOLD , teach ,
11+ showCancel , toggleShortcuts , SCROLL_THRESHOLD , teach , showToast ,
1212} from './utils.js' ;
1313import { addMessage , resetTurnState , showLoading , paintIntent } from './render.js' ;
14- import { maybeHandleComposerEnter } from './commands.js' ;
14+ import { maybeHandleComposerEnter , paletteItems , isComposerSlashInput } from './commands.js' ;
1515
1616function queueId ( ) {
1717 return 'q' + Date . now ( ) . toString ( 36 ) + Math . random ( ) . toString ( 36 ) . slice ( 2 , 6 ) ;
@@ -69,7 +69,9 @@ function moveQueue(i, delta) {
6969
7070export function drainQueue ( ) {
7171 // Do not auto-send through a live approval — the operator is still deciding.
72+ // A dead socket must not consume the queue either; reconnect drains it.
7273 if ( S . busy || S . activeApprovalId || ! S . promptQueue . length ) return ;
74+ if ( ! S . ws || S . ws . readyState !== WebSocket . OPEN ) return ;
7375 const next = S . promptQueue . shift ( ) ;
7476 renderQueueStrip ( ) ;
7577 sendPayload ( next . text , next . attachments , next . display , next . model , next . thinking ) ;
@@ -79,7 +81,10 @@ S.drainQueue = drainQueue;
7981// ── Send ──
8082export function send ( ) {
8183 // F-B2: dead socket still rejects BEFORE touching attachments.
82- if ( ! S . ws || S . ws . readyState !== WebSocket . OPEN ) return ;
84+ if ( ! S . ws || S . ws . readyState !== WebSocket . OPEN ) {
85+ showToast ( 'connection lost — reconnecting' ) ;
86+ return ;
87+ }
8388 const text = promptEl . value . trim ( ) ;
8489 if ( ! text && S . attachedFiles . length === 0 ) return ;
8590
@@ -326,13 +331,17 @@ promptEl.addEventListener('keydown', (e) => {
326331 return ;
327332 }
328333 if ( e . key === 'Enter' && ! e . shiftKey ) {
329- e . preventDefault ( ) ;
330- selectCompletion ( ) ;
331- return ;
334+ if ( S . compMode === 'slash' && ! isComposerSlashInput ( promptEl . value , promptEl . selectionStart ) ) {
335+ hideCompletion ( ) ;
336+ } else {
337+ e . preventDefault ( ) ;
338+ selectCompletion ( ) ;
339+ return ;
340+ }
332341 }
333342 if ( e . key === 'Escape' ) {
334343 e . preventDefault ( ) ;
335- completionEl . classList . remove ( 'visible' ) ;
344+ hideCompletion ( ) ;
336345 return ;
337346 }
338347 }
@@ -392,25 +401,37 @@ promptEl.addEventListener('input', () => {
392401} ) ;
393402
394403promptEl . addEventListener ( 'keydown' , ( e ) => {
395- if ( e . key === '@' ) {
404+ if ( e . key === '@' || e . key === '/' ) {
396405 if ( completionTimer ) clearTimeout ( completionTimer ) ;
397406 completionTimer = setTimeout ( checkCompletion , 150 ) ;
398407 }
399408
400409 // Tab for completion selection
401410 if ( e . key === 'Tab' && completionEl . classList . contains ( 'visible' ) ) {
402- e . preventDefault ( ) ;
403- selectCompletion ( ) ;
404- return ;
411+ if ( S . compMode === 'slash' && ! isComposerSlashInput ( promptEl . value , promptEl . selectionStart ) ) {
412+ hideCompletion ( ) ;
413+ } else {
414+ e . preventDefault ( ) ;
415+ selectCompletion ( ) ;
416+ return ;
417+ }
405418 }
406419} ) ;
407420
408421// ── @ Completion ──
409422completionEl . addEventListener ( 'click' , ( e ) => {
410423 const item = e . target . closest ( '.comp-item' ) ;
411424 if ( ! item ) return ;
425+ if ( S . compMode === 'slash' ) {
426+ completionEl . querySelectorAll ( '.comp-item' ) . forEach ( el => {
427+ el . classList . toggle ( 'selected' , el === item ) ;
428+ el . setAttribute ( 'aria-selected' , el === item ) ;
429+ } ) ;
430+ selectCompletion ( ) ;
431+ return ;
432+ }
412433 replaceCompletion ( item . dataset . id ) ;
413- completionEl . classList . remove ( 'visible' ) ;
434+ hideCompletion ( ) ;
414435} ) ;
415436
416437completionEl . addEventListener ( 'mousemove' , ( e ) => {
@@ -422,23 +443,57 @@ completionEl.addEventListener('mousemove', (e) => {
422443 } ) ;
423444} ) ;
424445
446+ let slashRows = [ ] ;
447+
448+ function hideCompletion ( ) {
449+ completionEl . classList . remove ( 'visible' ) ;
450+ S . compMode = '' ;
451+ slashRows = [ ] ;
452+ }
453+
454+ function trySlashCompletion ( val , cursor ) {
455+ if ( ! isComposerSlashInput ( val , cursor ) ) return false ;
456+ const q = val . slice ( 1 , cursor ) ;
457+ slashRows = paletteItems ( q ) ;
458+ S . compMode = 'slash' ;
459+ S . lastAtIdx = 0 ;
460+ S . lastCursor = cursor ;
461+ S . compQuery = q ;
462+ if ( ! slashRows . length ) {
463+ hideCompletion ( ) ;
464+ return true ;
465+ }
466+ completionEl . innerHTML = slashRows . map ( ( r , i ) =>
467+ `<div class="comp-item${ i === 0 ? ' selected' : '' } " role="option" aria-selected="${ i === 0 } " data-idx="${ i } ">
468+ <span class="comp-type">${ escapeAttr ( r . kind ) } </span>
469+ <span class="comp-label">${ escapeHtml ( r . title ) } </span>
470+ <span class="comp-detail">${ escapeHtml ( r . hint || '' ) } </span>
471+ </div>`
472+ ) . join ( '' ) ;
473+ completionEl . classList . add ( 'visible' ) ;
474+ return true ;
475+ }
476+
425477async function checkCompletion ( ) {
426478 const val = promptEl . value ;
427479 const cursor = promptEl . selectionStart ;
480+ if ( trySlashCompletion ( val , cursor ) ) return ;
481+
428482 const before = val . slice ( 0 , cursor ) ;
429483
430484 const atIdx = before . lastIndexOf ( '@' ) ;
431485 if ( atIdx < 0 ) {
432- completionEl . classList . remove ( 'visible' ) ;
486+ hideCompletion ( ) ;
433487 return ;
434488 }
435489
436490 const query = before . slice ( atIdx + 1 ) . split ( / \s / ) [ 0 ] ;
437491 if ( ! query ) {
438- completionEl . classList . remove ( 'visible' ) ;
492+ hideCompletion ( ) ;
439493 return ;
440494 }
441495
496+ S . compMode = 'at' ;
442497 S . lastAtIdx = atIdx ;
443498 S . lastCursor = cursor ;
444499 S . compQuery = query ;
@@ -451,12 +506,12 @@ async function checkCompletion() {
451506 headers : apiHeaders ( )
452507 } ) ;
453508 if ( ! resp . ok ) {
454- completionEl . classList . remove ( 'visible' ) ;
509+ hideCompletion ( ) ;
455510 return ;
456511 }
457512 const results = await resp . json ( ) ;
458513 if ( ! Array . isArray ( results ) || results . length === 0 ) {
459- completionEl . classList . remove ( 'visible' ) ;
514+ hideCompletion ( ) ;
460515 return ;
461516 }
462517 if ( promptEl . value + '\u0000' + promptEl . selectionStart !== reqToken ) {
@@ -473,7 +528,7 @@ async function checkCompletion() {
473528
474529 completionEl . classList . add ( 'visible' ) ;
475530 } catch {
476- completionEl . classList . remove ( 'visible' ) ;
531+ hideCompletion ( ) ;
477532 }
478533}
479534
@@ -491,10 +546,23 @@ function moveCompletionSelection(delta) {
491546}
492547
493548function selectCompletion ( ) {
549+ if ( S . compMode === 'slash' ) {
550+ const selected = completionEl . querySelector ( '.selected' ) ;
551+ const idx = selected ? parseInt ( selected . dataset . idx , 10 ) : 0 ;
552+ const row = slashRows [ idx ] ;
553+ hideCompletion ( ) ;
554+ promptEl . value = '' ;
555+ promptEl . style . height = 'auto' ;
556+ if ( row && row . run ) {
557+ try { row . run ( ) ; } catch ( err ) { showToast ( err . message || 'command failed' ) ; }
558+ }
559+ promptEl . focus ( ) ;
560+ return ;
561+ }
494562 const selected = completionEl . querySelector ( '.selected' ) ;
495563 if ( ! selected ) return ;
496564 replaceCompletion ( selected . dataset . id ) ;
497- completionEl . classList . remove ( 'visible' ) ;
565+ hideCompletion ( ) ;
498566}
499567
500568function replaceCompletion ( id ) {
0 commit comments