Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
isFocused={isFocused}
isPending={isPending}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
memoryWriteCount={itemForDisplay.memoryWriteCount}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,14 @@ function setup(initial: readonly DialogEntry[]): Harness {
act(() => handle.current!.setEntries(next));
},
pressKey(key) {
// Real `useKeypress` unbinds the previous callback on rerender, so
// only the most recently registered closure should run. Calling all
// accumulated handlers misses state updates that happened between
// renders (the older closures see stale state) — the symptom looks
// like a re-render race in production code that doesn't exist.
act(() => {
for (const h of handlers) h(key);
const latest = handlers[handlers.length - 1];
if (latest) latest(key);
});
},
call(fn) {
Expand Down Expand Up @@ -199,6 +205,65 @@ describe('BackgroundTasksDialog', () => {
expect(h.probe.current!.state.dialogMode).toBe('detail');
});

it('foreground cancel requires two `x` presses to confirm (one-press is a no-op)', () => {
// Foreground entries block the parent's tool-call: cancelling one ends
// the current turn with a partial result for that subagent. The dialog
// gates the destructive action behind a confirm step so the user can't
// wipe out their turn with a stray keypress.
const fg = entry({
agentId: 'fg-1',
status: 'running',
flavor: 'foreground',
});
const h = setup([fg]);

h.call(() => h.probe.current!.actions.openDialog());

h.pressKey({ sequence: 'x' });
expect(h.cancel).not.toHaveBeenCalled();

h.pressKey({ sequence: 'x' });
expect(h.cancel).toHaveBeenCalledWith('fg-1');
});

it('background cancel still fires on the first `x` press (no confirm)', () => {
// Backwards compatibility: the existing background-only cancel UX
// stays one-shot. Adding a confirm there would regress every workflow
// that relies on quickly cancelling a long-running async agent.
const bg = entry({
agentId: 'bg-1',
status: 'running',
flavor: 'background',
});
const h = setup([bg]);

h.call(() => h.probe.current!.actions.openDialog());

h.pressKey({ sequence: 'x' });
expect(h.cancel).toHaveBeenCalledWith('bg-1');
});

it('Esc backs out of an armed foreground cancel without closing the dialog', () => {
const fg = entry({
agentId: 'fg-1',
status: 'running',
flavor: 'foreground',
});
const h = setup([fg]);

h.call(() => h.probe.current!.actions.openDialog());

h.pressKey({ sequence: 'x' });
h.pressKey({ name: 'escape' });
// Dialog still open — Esc on the armed cancel resets the confirm
// state instead of nuking the dialog.
expect(h.probe.current!.state.dialogOpen).toBe(true);

// After the Esc reset, the next `x` arms again rather than confirming.
h.pressKey({ sequence: 'x' });
expect(h.cancel).not.toHaveBeenCalled();
});

it('clamps selectedIndex when entries shrink', () => {
const a = entry({ agentId: 'a' });
const b = entry({ agentId: 'b' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,24 @@ function terminalStatusPresentation(
}
}

// Foreground agent rows get this prefix so users can tell at a glance
// that cancelling one will end the parent's current turn — a much heavier
// consequence than cancelling a truly async background entry.
const FOREGROUND_ROW_PREFIX = '[in turn]';
const SHELL_ROW_PREFIX = '[shell]';

function rowLabel(entry: DialogEntry): string {
if (entry.kind === 'agent') {
return buildBackgroundEntryLabel(entry, { includePrefix: false });
const label = buildBackgroundEntryLabel(entry, { includePrefix: false });
return entry.flavor === 'foreground'
? `${FOREGROUND_ROW_PREFIX} ${label}`
: label;
}
// Shell row: `[shell] <command>`. Prefix mirrors the dialog's "section"
// visual hint without needing per-kind section headers (which would
// complicate the windowing math). The command itself is plain text and
// already truncated by the row renderer's MaxSizedBox.
return `[shell] ${entry.command}`;
// Shell prefix mirrors the dialog's "section" visual hint without needing
// per-kind section headers (which would complicate the windowing math).
// The command itself is plain text and already truncated by the row
// renderer's MaxSizedBox.
return `${SHELL_ROW_PREFIX} ${entry.command}`;
}

function elapsedFor(entry: { startTime: number; endTime?: number }): string {
Expand Down Expand Up @@ -495,6 +504,15 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
// those transitions — so we re-read from the registry here.
const [activityTick, setActivityTick] = useState(0);

// Two-step cancel for foreground entries: cancelling one ends the
// parent's current turn with a partial result for that subagent —
// a much heavier consequence than cancelling a background async task.
// `pendingCancelEntryId` records the entry that has been armed for
// cancellation; the next `x` press confirms. Esc resets.
const [pendingCancelEntryId, setPendingCancelEntryId] = useState<
string | null
>(null);

const selectedEntry = useMemo(() => {
const fromSnapshot = entries[selectedIndex] ?? null;
if (!fromSnapshot || fromSnapshot.kind !== 'agent') return fromSnapshot;
Expand Down Expand Up @@ -588,29 +606,52 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
}
}, [dialogOpen, dialogMode, selectedEntryId, selectedStatus, exitDetail]);

// Encapsulates the cancel flow with the foreground confirm-step.
// Foreground entries: first `x` arms; second `x` confirms. Background
// and shell entries: one-shot cancel (no behavior change).
const handleCancelKey = () => {
if (!selectedEntry) return;
const entryKey = entryId(selectedEntry);
const isForegroundAgent =
selectedEntry.kind === 'agent' && selectedEntry.flavor === 'foreground';
if (isForegroundAgent && pendingCancelEntryId !== entryKey) {
setPendingCancelEntryId(entryKey);
return;
}
setPendingCancelEntryId(null);
cancelSelected();
};

useKeypress(
(key) => {
if (!dialogOpen) return;

if (dialogMode === 'list') {
if (key.name === 'up') {
moveSelectionUp();
setPendingCancelEntryId(null);
return;
}
if (key.name === 'down') {
moveSelectionDown();
setPendingCancelEntryId(null);
return;
}
if (key.name === 'return') {
if (selectedEntry) enterDetail();
return;
}
if (key.name === 'escape' || key.name === 'left') {
if (pendingCancelEntryId) {
// Esc backs out of the confirm step before closing the dialog.
setPendingCancelEntryId(null);
return;
}
closeDialog();
return;
}
if (key.sequence === 'x' && !key.ctrl && !key.meta) {
cancelSelected();
handleCancelKey();
return;
}
// Note: the "stop all agents" chord (ctrl+x ctrl+k in claw-code)
Expand All @@ -631,11 +672,15 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
key.name === 'return' ||
key.name === 'space'
) {
if (pendingCancelEntryId && key.name === 'escape') {
setPendingCancelEntryId(null);
return;
}
closeDialog();
return;
}
if (key.sequence === 'x' && !key.ctrl && !key.meta) {
cancelSelected();
handleCancelKey();
return;
}
},
Expand All @@ -645,8 +690,18 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
if (!dialogOpen) return null;

// Hint footer — context-sensitive.
const selectedEntryKey = selectedEntry ? entryId(selectedEntry) : null;
const showCancelConfirmHint =
pendingCancelEntryId !== null && pendingCancelEntryId === selectedEntryKey;
const hints: string[] = [];
if (dialogMode === 'list') {
if (showCancelConfirmHint) {
// Force the confirmation step into the hint row so the user sees
// exactly what the next `x` will do.
hints.push(
'x again to confirm stop \u00b7 ends current turn',
'Esc cancel',
);
} else if (dialogMode === 'list') {
hints.push('\u2191/\u2193 select', 'Enter view');
if (selectedEntry?.status === 'running') hints.push('x stop');
hints.push('\u2190/Esc close');
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ interface ToolGroupMessageProps {
availableTerminalHeight?: number;
contentWidth: number;
isFocused?: boolean;
/**
* True when this tool group is being rendered live (in
* `pendingHistoryItems`). False once it commits to Ink's `<Static>`.
* The subagent renderer uses this to suppress the live frame for
* foreground subagents (the pill+dialog handle live drill-down) while
* keeping the committed scrollback render unchanged.
*/
isPending?: boolean;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
Expand All @@ -58,6 +66,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight,
contentWidth,
isFocused = true,
isPending = false,
activeShellPtyId,
embeddedShellFocused,
memoryWriteCount,
Expand Down Expand Up @@ -292,6 +301,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isAgentWithPendingConfirmation(tool.resultDisplay)
}
isFocused={isSubagentFocused}
isPending={isPending}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
</Box>
Expand Down
Loading
Loading