Skip to content

Commit d07e5fc

Browse files
grypezclaude
andcommitted
feat(kernel-tui): provision editor with per-arg pattern tuning
Adds a '2' keybind on pending session-detail entries that opens an interactive provision editor before granting a standing provision. The editor flattens all invocation args into a navigable list. Each arg cycles through its pattern interval (exact → prefix levels → wildcard) via ↑/↓; ←/→ moves between args. Enter submits the shaped Provision; Esc cancels back to the normal view. Falls back to a tool-level wildcard provision when invocations data is unavailable (unparseable command or old daemon). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb5e686 commit d07e5fc

3 files changed

Lines changed: 269 additions & 12 deletions

File tree

packages/kernel-tui/src/components/session-detail-view.tsx

Lines changed: 266 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
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';
111
import { Box, Text, useInput, useStdout } from 'ink';
212
import 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>

packages/kernel-tui/src/components/status-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type StatusBarProps = {
99
};
1010

1111
const VIEW_HINTS: Record<ViewMode, string> = {
12-
sessions: '↑/↓: navigate | 1: accept | 3: reject | R: refresh',
12+
sessions: '↑/↓: navigate | 1: accept | 2: provision | 3: reject | R: refresh',
1313
files: 'Select a bundle to launch',
1414
objects: 'r: refresh',
1515
invoke: 'Tab: next field | Enter on args: send',

packages/kernel-tui/src/hooks/use-kernel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getSocketPath,
33
sendCommand,
44
} from '@metamask/kernel-node-runtime/daemon';
5+
import type { ParsedInvocation } from '@metamask/kernel-utils/session';
56
import { useEffect, useRef, useState } from 'react';
67

78
import type { KernelApi, KernelStatus } from '../types.ts';
@@ -94,6 +95,7 @@ export function makeDaemonKernelApi(
9495
queuedAt: string;
9596
status: 'pending' | 'accepted' | 'rejected';
9697
decidedAt?: string;
98+
invocations?: ParsedInvocation[];
9799
}[]
98100
>('session.history', { sessionId });
99101
},

0 commit comments

Comments
 (0)