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
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ import {
useBackgroundTaskViewState,
} from '../../contexts/BackgroundTaskViewContext.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
import { useBackgroundTaskView } from '../../hooks/useBackgroundTaskView.js';
import {
useBackgroundTaskView,
type DialogEntry,
} from '../../hooks/useBackgroundTaskView.js';
import { useKeypress } from '../../hooks/useKeypress.js';

vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
useBackgroundTaskView: vi.fn(),
// Re-export the helper so Dialog renderers can still resolve it under the
// mocked module. Inline impl keeps the test independent of the hook
// module while preserving the discriminator-based id contract.
entryId: (entry: DialogEntry): string =>
entry.kind === 'agent' ? entry.agentId : entry.shellId,
}));

vi.mock('../../hooks/useKeypress.js', () => ({
Expand All @@ -30,33 +38,34 @@ vi.mock('../../hooks/useKeypress.js', () => ({
const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView);
const mockedUseKeypress = vi.mocked(useKeypress);

function entry(overrides: Partial<BackgroundTaskEntry>): BackgroundTaskEntry {
function entry(overrides: Partial<BackgroundTaskEntry> = {}): DialogEntry {
return {
kind: 'agent',
agentId: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
};
} as DialogEntry;
}

interface ProbeHandle {
actions: ReturnType<typeof useBackgroundTaskViewActions>;
state: ReturnType<typeof useBackgroundTaskViewState>;
setEntries: (next: readonly BackgroundTaskEntry[]) => void;
setEntries: (next: readonly DialogEntry[]) => void;
}

interface Harness {
cancel: ReturnType<typeof vi.fn>;
setEntries: (next: readonly BackgroundTaskEntry[]) => void;
setEntries: (next: readonly DialogEntry[]) => void;
pressKey: (key: { name?: string; sequence?: string }) => void;
call: (fn: () => void) => void;
lastFrame: () => string | undefined;
probe: { current: ProbeHandle | null };
}

function setup(initial: readonly BackgroundTaskEntry[]): Harness {
function setup(initial: readonly DialogEntry[]): Harness {
const handlers: Array<(key: { name?: string; sequence?: string }) => void> =
[];
mockedUseKeypress.mockImplementation((cb, opts) => {
Expand Down Expand Up @@ -94,7 +103,7 @@ function setup(initial: readonly BackgroundTaskEntry[]): Harness {
function Probe({
entriesSetter,
}: {
entriesSetter: (e: readonly BackgroundTaskEntry[]) => void;
entriesSetter: (e: readonly DialogEntry[]) => void;
}) {
handle.current = {
actions: useBackgroundTaskViewActions(),
Expand Down
153 changes: 132 additions & 21 deletions packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ import {
ToolNames,
type BackgroundTaskEntry,
} from '@qwen-code/qwen-code-core';
import {
type DialogEntry,
entryId,
} from '../../hooks/useBackgroundTaskView.js';

// `DialogEntry['status']` widens BackgroundTaskEntry['status'] with the
// shell status union, but they share the same four values
// (running / completed / failed / cancelled), so handlers keyed on the
// agent enum still cover every shell case.
type EntryStatus = DialogEntry['status'];

// Tool-name → display-name lookup (`run_shell_command` → `Shell`).
const TOOL_DISPLAY_BY_NAME: Record<string, string> = Object.fromEntries(
Expand All @@ -46,7 +56,7 @@ function formatActivityLabel(name: string, description: string | undefined) {
}
import { formatDuration, formatTokenCount } from '../../utils/formatters.js';

const STATUS_VERBS: Record<BackgroundTaskEntry['status'], string> = {
const STATUS_VERBS: Record<EntryStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed',
Expand All @@ -60,7 +70,7 @@ interface StatusPresentation {
}

function terminalStatusPresentation(
status: BackgroundTaskEntry['status'],
status: EntryStatus,
): StatusPresentation | null {
switch (status) {
case 'completed':
Expand All @@ -86,11 +96,18 @@ function terminalStatusPresentation(
}
}

function rowLabel(entry: BackgroundTaskEntry): string {
return buildBackgroundEntryLabel(entry, { includePrefix: false });
function rowLabel(entry: DialogEntry): string {
if (entry.kind === 'agent') {
return buildBackgroundEntryLabel(entry, { includePrefix: false });
}
// 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}`;
}

function elapsedFor(entry: BackgroundTaskEntry): string {
function elapsedFor(entry: { startTime: number; endTime?: number }): string {
const elapsedMs = Math.max(
0,
(entry.endTime ?? Date.now()) - entry.startTime,
Expand Down Expand Up @@ -126,7 +143,7 @@ function truncateToWidth(text: string, maxWidth: number): string {
// ─── List mode ─────────────────────────────────────────────

const ListBody: React.FC<{
entries: readonly BackgroundTaskEntry[];
entries: readonly DialogEntry[];
selectedIndex: number;
maxRows: number;
}> = ({ entries, selectedIndex, maxRows }) => {
Expand All @@ -137,7 +154,7 @@ const ListBody: React.FC<{
return (
<Box flexDirection="column">
<Box paddingX={1}>
<Text bold>Local agents</Text>
<Text bold>Background tasks</Text>
<Text color={theme.text.secondary}> (0)</Text>
</Box>
<Box paddingX={1}>
Expand Down Expand Up @@ -193,7 +210,7 @@ const ListBody: React.FC<{
? terminal.labelColor
: theme.text.primary;
return (
<Box key={entry.agentId} flexDirection="row" paddingX={1}>
<Box key={entryId(entry)} flexDirection="row" paddingX={1}>
<Text color={isSelected ? theme.text.accent : undefined}>
{isSelected ? '> ' : ' '}
</Text>
Expand All @@ -216,11 +233,22 @@ const ListBody: React.FC<{
// ─── Detail mode ───────────────────────────────────────────

const DetailBody: React.FC<{
entry: DialogEntry;
maxHeight: number;
maxWidth: number;
}> = ({ entry, maxHeight, maxWidth }) =>
entry.kind === 'agent' ? (
<AgentDetailBody entry={entry} maxHeight={maxHeight} maxWidth={maxWidth} />
) : (
<ShellDetailBody entry={entry} maxHeight={maxHeight} maxWidth={maxWidth} />
);

const AgentDetailBody: React.FC<{
entry: BackgroundTaskEntry;
maxHeight: number;
maxWidth: number;
}> = ({ entry, maxHeight, maxWidth }) => {
const title = `${entry.subagentType ?? 'Agent'} \u203A ${rowLabel(entry)}`;
const title = `${entry.subagentType ?? 'Agent'} \u203A ${buildBackgroundEntryLabel(entry, { includePrefix: false })}`;

const terminal = terminalStatusPresentation(entry.status);
const dimSubtitleParts: string[] = [elapsedFor(entry)];
Expand Down Expand Up @@ -342,6 +370,85 @@ const DetailBody: React.FC<{
);
};

const ShellDetailBody: React.FC<{
entry: import('@qwen-code/qwen-code-core').BackgroundShellEntry;
maxHeight: number;
maxWidth: number;
}> = ({ entry, maxHeight, maxWidth }) => {
const title = `Shell \u203A ${entry.command}`;

const terminal = terminalStatusPresentation(entry.status);
const dimSubtitleParts: string[] = [elapsedFor(entry)];
if (entry.pid !== undefined) {
dimSubtitleParts.push(`pid ${entry.pid}`);
}
if (entry.status === 'completed' && entry.exitCode !== undefined) {
dimSubtitleParts.push(`exit ${entry.exitCode}`);
}

const hasError = entry.status === 'failed' && Boolean(entry.error);

return (
<MaxSizedBox
maxHeight={maxHeight}
maxWidth={maxWidth}
overflowDirection="bottom"
>
<Box>
<Text bold color={theme.text.accent}>
{title}
</Text>
</Box>
<Box>
{terminal && (
<Text color={terminal.color}>
{`${terminal.icon} ${STATUS_VERBS[entry.status]} \u00B7 `}
</Text>
)}
<Text color={theme.text.secondary}>
{dimSubtitleParts.join(' \u00B7 ')}
</Text>
</Box>

<Box />
<Box>
<Text bold dimColor>
Working dir
</Text>
</Box>
<Box>
<Text wrap="truncate-end">{entry.cwd}</Text>
</Box>

<Box />
<Box>
<Text bold dimColor>
Output file
</Text>
</Box>
<Box>
<Text wrap="truncate-end">{entry.outputPath}</Text>
</Box>

{hasError && (
<Fragment>
<Box />
<Box>
<Text bold color={theme.status.error}>
Error
</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{entry.error}
</Text>
</Box>
</Fragment>
)}
</MaxSizedBox>
);
};

// ─── Dialog shell ──────────────────────────────────────────

interface BackgroundTasksDialogProps {
Expand Down Expand Up @@ -390,17 +497,21 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
// activity updates so the Footer pill and AppContainer don't re-run
// on every tool call a background agent makes.
const [, bumpActivity] = useState(0);
const selectedAgentId = selectedEntry?.agentId;
const selectedEntryId = selectedEntry ? entryId(selectedEntry) : undefined;
// Activity callback is agent-only — shells don't emit per-tool events.
const selectedAgentIdForActivity =
selectedEntry?.kind === 'agent' ? selectedEntry.agentId : undefined;
useEffect(() => {
if (!dialogOpen || dialogMode !== 'detail' || !selectedAgentId) return;
if (!dialogOpen || dialogMode !== 'detail' || !selectedAgentIdForActivity)
return;
const registry = config.getBackgroundTaskRegistry();
const onActivity = (entry: BackgroundTaskEntry) => {
if (entry.agentId !== selectedAgentId) return;
if (entry.agentId !== selectedAgentIdForActivity) return;
bumpActivity((n) => n + 1);
};
registry.setActivityChangeCallback(onActivity);
return () => registry.setActivityChangeCallback(undefined);
}, [dialogOpen, dialogMode, config, selectedAgentId]);
}, [dialogOpen, dialogMode, config, selectedAgentIdForActivity]);

// Wall-clock tick for the running agent's duration. Activity callbacks
// fire when tools run, but duration needs to advance even when the agent
Expand All @@ -410,13 +521,13 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
if (
!dialogOpen ||
dialogMode !== 'detail' ||
!selectedAgentId ||
!selectedEntryId ||
selectedStatus !== 'running'
)
return;
const id = setInterval(() => bumpActivity((n) => n + 1), 1000);
return () => clearInterval(id);
}, [dialogOpen, dialogMode, selectedAgentId, selectedStatus]);
}, [dialogOpen, dialogMode, selectedEntryId, selectedStatus]);

// Auto-fallback to the list view when the selected agent reaches a
// terminal state while the user is watching it live. We only exit on
Expand All @@ -425,8 +536,8 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
// view itself renders terminal state fine, so this is a UX choice
// (return focus to the running roster) rather than a correctness fix.
const initialDetailStatusRef = useRef<{
agentId: string;
status: BackgroundTaskEntry['status'];
entryId: string;
status: EntryStatus;
} | null>(null);
useEffect(() => {
if (!dialogOpen || dialogMode !== 'detail') {
Expand All @@ -437,18 +548,18 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
// drop back to the list so we don't sit on a "No entry to show" screen.
// Hitting this path now is unlikely — terminal entries stay in the
// registry — but the entry could disappear if the registry is reset.
if (!selectedAgentId) {
if (!selectedEntryId) {
initialDetailStatusRef.current = null;
exitDetail();
return;
}
const seen = initialDetailStatusRef.current;
if (!seen || seen.agentId !== selectedAgentId) {
if (!seen || seen.entryId !== selectedEntryId) {
// First render in detail mode for this entry — remember the status we
// opened with so we can detect a transition away from 'running' later.
if (selectedStatus) {
initialDetailStatusRef.current = {
agentId: selectedAgentId,
entryId: selectedEntryId,
status: selectedStatus,
};
}
Expand All @@ -461,7 +572,7 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
) {
exitDetail();
}
}, [dialogOpen, dialogMode, selectedAgentId, selectedStatus, exitDetail]);
}, [dialogOpen, dialogMode, selectedEntryId, selectedStatus, exitDetail]);

useKeypress(
(key) => {
Expand Down
Loading
Loading