1+ import type {
2+ ArgPattern ,
3+ ParsedInvocation ,
4+ Provision ,
5+ } from '@metamask/kernel-utils/session' ;
6+ import {
7+ argInterval ,
8+ argPatternDisplay ,
9+ invocationToProvision ,
10+ } from '@metamask/kernel-utils/session' ;
111import { Box , Text , useInput , useStdout } from 'ink' ;
212import React , { useEffect , useMemo , useState } from 'react' ;
313
@@ -409,13 +419,206 @@ function clampScroll(
409419 return newOffset ;
410420}
411421
422+ type FlatArg = {
423+ invIdx : number ;
424+ argIdx : number ;
425+ value : string ;
426+ interval : ArgPattern [ ] ;
427+ } ;
428+
429+ type ProvisionEditorProps = {
430+ toolName : string ;
431+ invocations : ParsedInvocation [ ] ;
432+ onSubmit : ( provision : Provision ) => void ;
433+ onCancel : ( ) => void ;
434+ } ;
435+
436+ /**
437+ * Interactive editor that lets the user tune each arg in a pending invocation
438+ * to a wider pattern (prefix or wildcard) before granting a standing provision.
439+ *
440+ * Keybinds: ←/→ navigate args, ↑ widen, ↓ narrow, Enter submit, Esc cancel.
441+ *
442+ * @param props - Component props.
443+ * @param props.toolName - The tool name (e.g. "Bash").
444+ * @param props.invocations - The parsed invocations for the pending request.
445+ * @param props.onSubmit - Called with the resulting Provision when Enter is pressed.
446+ * @param props.onCancel - Called when Esc is pressed.
447+ * @returns The ProvisionEditor component.
448+ */
449+ function ProvisionEditor ( {
450+ toolName,
451+ invocations,
452+ onSubmit,
453+ onCancel,
454+ } : ProvisionEditorProps ) : React . ReactElement {
455+ const flatArgs = useMemo < FlatArg [ ] > ( ( ) => {
456+ const result : FlatArg [ ] = [ ] ;
457+ for ( let i = 0 ; i < invocations . length ; i ++ ) {
458+ const inv = invocations [ i ] ;
459+ if ( inv === undefined ) {
460+ continue ;
461+ }
462+ for ( let j = 0 ; j < inv . argv . length ; j ++ ) {
463+ const value = inv . argv [ j ] ;
464+ if ( value !== undefined ) {
465+ result . push ( {
466+ invIdx : i ,
467+ argIdx : j ,
468+ value,
469+ interval : argInterval ( value ) ,
470+ } ) ;
471+ }
472+ }
473+ }
474+ return result ;
475+ } , [ invocations ] ) ;
476+
477+ const [ cursor , setCursor ] = useState ( 0 ) ;
478+ const [ sels , setSels ] = useState < number [ ] > ( ( ) => flatArgs . map ( ( ) => 0 ) ) ;
479+
480+ const currentFlatArg = flatArgs [ cursor ] ;
481+ const currentSel = sels [ cursor ] ?? 0 ;
482+ const currentPattern = currentFlatArg ?. interval [ currentSel ] ;
483+
484+ useInput ( ( _input , key ) => {
485+ if ( key . escape ) {
486+ onCancel ( ) ;
487+ } else if ( key . return ) {
488+ const provision =
489+ flatArgs . length === 0
490+ ? invocationToProvision ( toolName , invocations )
491+ : buildProvision ( toolName , invocations , flatArgs , sels ) ;
492+ onSubmit ( provision ) ;
493+ } else if ( key . rightArrow ) {
494+ setCursor ( ( idx ) => Math . min ( flatArgs . length - 1 , idx + 1 ) ) ;
495+ } else if ( key . leftArrow ) {
496+ setCursor ( ( idx ) => Math . max ( 0 , idx - 1 ) ) ;
497+ } else if ( key . upArrow && currentFlatArg !== undefined ) {
498+ setSels ( ( prev ) => {
499+ const next = [ ...prev ] ;
500+ next [ cursor ] = Math . min (
501+ currentFlatArg . interval . length - 1 ,
502+ ( next [ cursor ] ?? 0 ) + 1 ,
503+ ) ;
504+ return next ;
505+ } ) ;
506+ } else if ( key . downArrow ) {
507+ setSels ( ( prev ) => {
508+ const next = [ ...prev ] ;
509+ next [ cursor ] = Math . max ( 0 , ( next [ cursor ] ?? 0 ) - 1 ) ;
510+ return next ;
511+ } ) ;
512+ }
513+ } ) ;
514+
515+ // Render invocations as a flat line with each arg colored by its pattern scope.
516+ // Cursor arg is highlighted; widened args appear in a different color.
517+ let flatIdx = 0 ;
518+ const invocationLines = invocations . map ( ( inv , invIdx ) => {
519+ const argNodes = inv . argv . map ( ( val , argIdx ) => {
520+ const fi = flatIdx ;
521+ flatIdx += 1 ;
522+ const sel = sels [ fi ] ?? 0 ;
523+ const interval = flatArgs [ fi ] ?. interval ?? argInterval ( val ) ;
524+ const pat = interval [ sel ] ;
525+ const display = pat === undefined ? val : argPatternDisplay ( pat ) ;
526+ const isCursor = fi === cursor ;
527+ const isWidened = sel > 0 ;
528+ let argColor : 'cyan' | 'yellow' | undefined ;
529+ if ( isCursor ) {
530+ argColor = 'cyan' ;
531+ } else if ( isWidened ) {
532+ argColor = 'yellow' ;
533+ }
534+ return (
535+ < Text
536+ key = { `${ invIdx } -${ argIdx } ` }
537+ { ...( argColor === undefined ? { } : { color : argColor } ) }
538+ bold = { isCursor }
539+ >
540+ { ' ' }
541+ { display }
542+ </ Text >
543+ ) ;
544+ } ) ;
545+ return (
546+ < React . Fragment key = { invIdx } >
547+ { invIdx > 0 && < Text dimColor > |</ Text > }
548+ < Text bold > { inv . name } </ Text >
549+ { argNodes }
550+ </ React . Fragment >
551+ ) ;
552+ } ) ;
553+
554+ return (
555+ < Box flexDirection = "column" paddingLeft = { 4 } marginTop = { 1 } >
556+ < Box gap = { 1 } flexWrap = "wrap" >
557+ { invocationLines }
558+ </ Box >
559+ { currentFlatArg !== undefined && currentPattern !== undefined && (
560+ < Box paddingLeft = { 2 } gap = { 1 } marginTop = { 0 } >
561+ < Text dimColor > ↕</ Text >
562+ < Text color = "cyan" > { argPatternDisplay ( currentPattern ) } </ Text >
563+ < Text dimColor >
564+ ({ currentFlatArg . interval . indexOf ( currentPattern ) + 1 } /
565+ { currentFlatArg . interval . length } )
566+ </ Text >
567+ </ Box >
568+ ) }
569+ { flatArgs . length === 0 && (
570+ < Text dimColor >
571+ { ' ' }
572+ (no args — will match any invocation of { toolName } )
573+ </ Text >
574+ ) }
575+ < Box marginTop = { 1 } >
576+ < Text dimColor >
577+ ←/→ navigate · ↑ widen · ↓ narrow · Enter grant · Esc cancel
578+ </ Text >
579+ </ Box >
580+ </ Box >
581+ ) ;
582+ }
583+
584+ /**
585+ * Build a Provision from the editor's current selections.
586+ *
587+ * @param toolName - The tool name.
588+ * @param invocations - The original parsed invocations.
589+ * @param flatArgs - Flattened arg list with intervals.
590+ * @param sels - Per-flat-arg selection indices into each interval.
591+ * @returns The constructed Provision.
592+ */
593+ function buildProvision (
594+ toolName : string ,
595+ invocations : ParsedInvocation [ ] ,
596+ flatArgs : FlatArg [ ] ,
597+ sels : number [ ] ,
598+ ) : Provision {
599+ let flatIdx = 0 ;
600+ return {
601+ tool : toolName ,
602+ patterns : invocations . map ( ( inv ) => ( {
603+ name : inv . name ,
604+ argPatterns : inv . argv . map ( ( val ) => {
605+ const fi = flatIdx ;
606+ flatIdx += 1 ;
607+ const sel = sels [ fi ] ?? 0 ;
608+ const interval = flatArgs [ fi ] ?. interval ?? argInterval ( val ) ;
609+ return interval [ sel ] ?? ( { kind : 'wildcard' } as const ) ;
610+ } ) ,
611+ } ) ) ,
612+ } ;
613+ }
614+
412615/**
413616 * Detail view for a single session showing a reverse-chronological timeline of
414617 * authorization requests (most recent at top). Each entry can be expanded with
415618 * the right arrow key and collapsed with the left arrow key. Left arrow on a
416619 * collapsed entry navigates back to the session list.
417620 *
418- * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, 1 accept, 3 reject.
621+ * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, 1 accept, 2 grant with provision, 3 reject.
419622 *
420623 * @param props - Component props.
421624 * @param props.session - The session being viewed.
@@ -444,6 +647,7 @@ export function SessionDetailView({
444647 const [ scrollOffset , setScrollOffset ] = useState ( 0 ) ;
445648 const [ deciding , setDeciding ] = useState ( false ) ;
446649 const [ error , setError ] = useState < string | null > ( null ) ;
650+ const [ editingProvision , setEditingProvision ] = useState ( false ) ;
447651
448652 const { stdout } = useStdout ( ) ;
449653 const columns = stdout . columns ?? 80 ;
@@ -515,6 +719,9 @@ export function SessionDetailView({
515719 const countBelow = displayEntries . length - visEnd ;
516720
517721 useInput ( ( input , key ) => {
722+ if ( editingProvision ) {
723+ return ; // ProvisionEditor handles its own input
724+ }
518725 if ( key . upArrow ) {
519726 const nextIdx = Math . max ( 0 , cursorIdx - 1 ) ;
520727 setFocusedToken ( displayEntries [ nextIdx ] ?. token ?? null ) ;
@@ -544,6 +751,11 @@ export function SessionDetailView({
544751 } else {
545752 onBack ( ) ;
546753 }
754+ } else if ( input === '2' && ! deciding ) {
755+ if ( focused === undefined || focused . status !== 'pending' ) {
756+ return ;
757+ }
758+ setEditingProvision ( true ) ;
547759 } else if ( ( input === '1' || input === '3' ) && ! deciding ) {
548760 if ( focused === undefined || focused . status !== 'pending' ) {
549761 return ;
@@ -565,6 +777,26 @@ export function SessionDetailView({
565777 }
566778 } ) ;
567779
780+ const handleProvisionSubmit = ( provision : Provision ) : void => {
781+ if ( focused === undefined || focused . status !== 'pending' ) {
782+ return ;
783+ }
784+ setEditingProvision ( false ) ;
785+ setDeciding ( true ) ;
786+ kernelApi
787+ . decide ( session . sessionId , focused . token , 'accept' , provision )
788+ . then ( ( ) => {
789+ onDecided ( ) ;
790+ return undefined ;
791+ } )
792+ . catch ( ( caught : Error ) => {
793+ setError ( caught . message ) ;
794+ } )
795+ . finally ( ( ) => {
796+ setDeciding ( false ) ;
797+ } ) ;
798+ } ;
799+
568800 return (
569801 < Box flexDirection = "column" paddingX = { 1 } >
570802 < Box gap = { 1 } >
@@ -585,14 +817,22 @@ export function SessionDetailView({
585817 const idx = displayEntries . indexOf ( entry ) ;
586818 const isFocused = idx === cursorIdx ;
587819 const isExpanded = expanded . has ( entry . token ) ;
820+ const isEditingThis =
821+ editingProvision && isFocused && entry . status === 'pending' ;
588822 const icon = STATUS_ICON [ entry . status ] ;
589823 const color = STATUS_COLOR [ entry . status ] ;
590824
591825 const { label } = parseDescription ( entry . description ) ;
592826
593- const expandedLines = isExpanded
594- ? formatExpandedContent ( entry . description ) . split ( '\n' )
595- : [ ] ;
827+ const expandedLines =
828+ isExpanded && ! isEditingThis
829+ ? formatExpandedContent ( entry . description ) . split ( '\n' )
830+ : [ ] ;
831+
832+ // Extract the tool name from the description label, e.g. "Allow Bash" → "Bash"
833+ const toolName = label . startsWith ( 'Allow ' )
834+ ? label . slice ( 'Allow ' . length )
835+ : label ;
596836
597837 return (
598838 < Box key = { entry . token } flexDirection = "column" marginTop = { 0 } >
@@ -602,21 +842,36 @@ export function SessionDetailView({
602842 < Text color = "cyan" dimColor >
603843 { formatTime ( entry . queuedAt ) }
604844 </ Text >
605- < Text bold = { isFocused } > { label } </ Text >
845+ < Text bold = { isFocused } >
846+ { label }
847+ { isEditingThis && (
848+ < Text color = "yellow" > (grant with provision…)</ Text >
849+ ) }
850+ </ Text >
606851 </ Box >
852+ { isEditingThis && (
853+ < ProvisionEditor
854+ toolName = { toolName }
855+ invocations = { entry . invocations ?? [ ] }
856+ onSubmit = { handleProvisionSubmit }
857+ onCancel = { ( ) => setEditingProvision ( false ) }
858+ />
859+ ) }
607860 { expandedLines . map ( ( line , lineIdx ) => (
608861 < Box key = { `${ entry . token } -${ lineIdx } ` } paddingLeft = { 4 } >
609862 < Text dimColor wrap = "wrap" >
610863 { line }
611864 </ Text >
612865 </ Box >
613866 ) ) }
614- { isExpanded && entry . decidedAt !== undefined && (
615- < Box paddingLeft = { 4 } >
616- < Text dimColor > decided { formatTime ( entry . decidedAt ) } </ Text >
617- </ Box >
618- ) }
619- { isExpanded && entry . guard . body !== '#{}' && (
867+ { isExpanded &&
868+ ! isEditingThis &&
869+ entry . decidedAt !== undefined && (
870+ < Box paddingLeft = { 4 } >
871+ < Text dimColor > decided { formatTime ( entry . decidedAt ) } </ Text >
872+ </ Box >
873+ ) }
874+ { isExpanded && ! isEditingThis && entry . guard . body !== '#{}' && (
620875 < Box paddingLeft = { 4 } >
621876 < Text dimColor > guard: { entry . guard . body } </ Text >
622877 </ Box >
0 commit comments