Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dbc9760
feat(cli): surface auto-memory dream tasks in Background tasks dialog
wenshao May 4, 2026
c6118b5
docs(cli): rephrase dream filter comment to focus on extract vs dream
wenshao May 4, 2026
3d8b978
feat(core,cli): cancel dream consolidation tasks via dialog and task_…
wenshao May 4, 2026
60568f1
fix(core,cli): address review feedback on dream cancellation surface
wenshao May 4, 2026
70b5761
fix(core): handle resolved-cancel path from runForkedAgent for dream …
wenshao May 4, 2026
50c0d55
fix(core,cli): address review feedback on dream UX, perf, and comment…
wenshao May 5, 2026
e6711c5
fix(core,cli): tighten cancelTask contract, dedup dream snapshot read…
wenshao May 5, 2026
5cc1582
fix(core,cli): address self-review + Copilot feedback on dream surface
wenshao May 5, 2026
2b87c6f
fix(core,cli): tighten error semantics + comments on dream surface
wenshao May 5, 2026
4ebd0b7
fix(core): skip scheduleDream early when params.config is missing
wenshao May 5, 2026
5de1a78
fix(core): plug subscribe Map leak + dream.ts late-cancel ordering bug
wenshao May 5, 2026
a139b07
fix(core): swallow releaseDreamLock errors so they don't poison outcome
wenshao May 5, 2026
a8664a9
fix(core): thread abortSignal into dream metadata writes
wenshao May 5, 2026
9b4df07
fix(core): close dream cancel race + surface lock-release failures
wenshao May 5, 2026
b00ecde
fix(core,cli): address 8 review findings on dream surface
wenshao May 5, 2026
90fffbf
fix(memory): same-session recovery when releaseDreamLock throws
wenshao May 5, 2026
90df8f4
fix(memory): real cancelled-dream duration + clarify index-rebuild or…
wenshao May 5, 2026
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
38 changes: 4 additions & 34 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

import type React from 'react';
import { useCallback, useSyncExternalStore } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
Expand All @@ -25,39 +24,12 @@ import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { t } from '../../i18n/index.js';

/**
* Returns true while any dream task for the current project is in
* 'pending' or 'running' state. Uses MemoryManager's subscribe/notify
* mechanism so there is zero polling overhead.
*/
function useDreamRunning(projectRoot: string): boolean {
const config = useConfig();

const subscribe = useCallback(
(onStoreChange: () => void) =>
config.getMemoryManager().subscribe(onStoreChange),
[config],
);

const getSnapshot = useCallback(
() =>
config
.getMemoryManager()
.listTasksByType('dream', projectRoot)
.some((task) => task.status === 'pending' || task.status === 'running'),
[config, projectRoot],
);

return useSyncExternalStore(subscribe, getSnapshot);
}

export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { lines: statusLineLines } = useStatusLine();
const configInitMessage = useConfigInitMessage(uiState.isConfigInitialized);
const dreamRunning = useDreamRunning(config.getProjectRoot());

const { promptTokenCount, showAutoAcceptIndicator } = {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
Expand Down Expand Up @@ -134,12 +106,10 @@ export const Footer: React.FC = () => {
node: <Text color={theme.status.warning}>Debug Mode</Text>,
});
}
if (dreamRunning) {
rightItems.push({
key: 'dream',
node: <Text color={theme.text.secondary}>{t('✦ dreaming')}</Text>,
});
}
// Dream tasks now surface via the BackgroundTasksPill (e.g. "1 dream")
// alongside the other background-task kinds. The previous `✦ dreaming`
// right-column indicator was removed to avoid two simultaneous signals
// for the same underlying state.
if (promptTokenCount > 0 && contextWindowSize) {
rightItems.push({
key: 'context',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { ConfigContext } from '../../contexts/ConfigContext.js';
import {
type AgentDialogEntry,
type DreamDialogEntry,
useBackgroundTaskView,
type DialogEntry,
} from '../../hooks/useBackgroundTaskView.js';
Expand All @@ -36,6 +37,8 @@ vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
return entry.shellId;
case 'monitor':
return entry.monitorId;
case 'dream':
return entry.dreamId;
default: {
const _exhaustive: never = entry;
throw new Error(
Expand All @@ -53,7 +56,7 @@ vi.mock('../../hooks/useKeypress.js', () => ({
const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView);
const mockedUseKeypress = vi.mocked(useKeypress);

function entry(overrides: Partial<AgentDialogEntry> = {}): DialogEntry {
function entry(overrides: Partial<AgentDialogEntry> = {}): AgentDialogEntry {
return {
kind: 'agent',
agentId: 'a',
Expand All @@ -62,7 +65,21 @@ function entry(overrides: Partial<AgentDialogEntry> = {}): DialogEntry {
startTime: 0,
abortController: new AbortController(),
...overrides,
} as DialogEntry;
} as AgentDialogEntry;
}

function dreamEntry(
overrides: Partial<DreamDialogEntry> = {},
): DreamDialogEntry {
return {
kind: 'dream',
dreamId: 'd-1',
status: 'running',
startTime: 0,
sessionCount: 7,
progressText: 'Scheduled managed auto-memory dream.',
...overrides,
};
}

function monitorEntry(overrides: Partial<DialogEntry> = {}): DialogEntry {
Expand Down Expand Up @@ -94,6 +111,7 @@ interface Harness {
resume: ReturnType<typeof vi.fn>;
abandon: ReturnType<typeof vi.fn>;
monitorCancel: ReturnType<typeof vi.fn>;
dreamCancelTask: ReturnType<typeof vi.fn>;
setEntries: (next: readonly DialogEntry[]) => void;
pressKey: (key: { name?: string; sequence?: string }) => void;
call: (fn: () => void) => void;
Expand All @@ -112,6 +130,7 @@ function setup(initial: readonly DialogEntry[]): Harness {
const resume = vi.fn();
const abandon = vi.fn();
const monitorCancel = vi.fn();
const dreamCancelTask = vi.fn();
// Stub registry that resolves `.get(agentId)` against the current entries
// snapshot — the dialog now re-reads agent entries via `.get()` to pick up
// live activity/stats mutations the snapshot misses.
Expand All @@ -138,6 +157,9 @@ function setup(initial: readonly DialogEntry[]): Harness {
return match;
},
}),
getMemoryManager: () => ({
cancelTask: dreamCancelTask,
}),
resumeBackgroundAgent: resume,
abandonBackgroundAgent: abandon,
} as unknown as Config;
Expand Down Expand Up @@ -182,6 +204,7 @@ function setup(initial: readonly DialogEntry[]): Harness {
resume,
abandon,
monitorCancel,
dreamCancelTask,
setEntries(next) {
handlers.length = 0;
currentEntries = next;
Expand Down Expand Up @@ -447,4 +470,119 @@ describe('BackgroundTasksDialog', () => {
expect(f).not.toContain('Stopped because');
});
});

describe('dream entries', () => {
// Coverage for the dream task kind in the unified pill / dialog
// plumbing — list rendering, detail body, hint visibility, and
// cancellation routing. Mirrors the agent / shell / monitor
// coverage profile so each kind has parity in this test file.
it('renders the [dream] row with session count in list mode', () => {
const h = setup([dreamEntry({ sessionCount: 7 })]);
h.call(() => h.probe.current!.actions.openDialog());

const f = h.lastFrame() ?? '';
expect(f).toContain('[dream]');
expect(f).toContain('memory consolidation');
expect(f).toContain('reviewing 7 sessions');
});

it('renders DreamDetailBody with sessions / progress / topics on detail view', () => {
const h = setup([
dreamEntry({
status: 'completed',
sessionCount: 5,
progressText: 'Managed auto-memory dream completed.',
touchedTopics: ['user', 'project', 'feedback'],
}),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());

const f = h.lastFrame() ?? '';
expect(f).toContain('Dream');
expect(f).toContain('Sessions reviewing');
expect(f).toContain('5');
expect(f).toContain('Progress');
expect(f).toContain('Managed auto-memory dream completed.');
expect(f).toContain('Topics touched (3)');
expect(f).toContain('user');
expect(f).toContain('project');
expect(f).toContain('feedback');
});

it('shows the "x stop" hint for a running dream entry', () => {
const h = setup([dreamEntry({ status: 'running' })]);
h.call(() => h.probe.current!.actions.openDialog());
const f = h.lastFrame() ?? '';
expect(f).toContain('x stop');
});

it("routes 'x' on a running dream to MemoryManager.cancelTask(dreamId)", () => {
// Pin the dream-cancel branch in `cancelSelected` — flipping it
// to anything else (e.g. shell's `requestCancel`) would silently
// break the only path the user has to stop a runaway dream
// consolidation, since the hint already advertises the action.
const h = setup([dreamEntry({ dreamId: 'd-zzz', status: 'running' })]);
h.call(() => h.probe.current!.actions.openDialog());
h.pressKey({ sequence: 'x' });
expect(h.dreamCancelTask).toHaveBeenCalledWith('d-zzz');
// Belt-and-braces — the registry-side cancel paths must not fire
// for a dream entry, otherwise the wrong AbortController gets
// signalled.
expect(h.cancel).not.toHaveBeenCalled();
expect(h.monitorCancel).not.toHaveBeenCalled();
});

it('omits the topics block entirely while the dream is still running', () => {
// Topics only get populated via metadata.touchedTopics on
// completion; mid-run the body should hide the section instead of
// rendering an empty header.
const h = setup([dreamEntry({ status: 'running', touchedTopics: [] })]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
expect(f).not.toContain('Topics touched');
});

it('renders the Error block on failed status with a "+ Stopped because" verb', () => {
// Dream failures need to surface — they are the user's only signal
// that consolidation didn't happen as expected (success path
// already produces a memory_saved toast in useGeminiStream).
const h = setup([
dreamEntry({
status: 'failed',
error: 'Dream agent failed: model timeout',
}),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
expect(f).toContain('Failed');
expect(f).toContain('Error');
expect(f).toContain('Dream agent failed: model timeout');
});

it('caps visible topics at 8 and renders a "+N more" tail for overflow', () => {
// Real consolidations can touch many memory files; the body must
// not push the hint footer off-screen. Cap mirrors MAX_TOPICS in
// DreamDetailBody.
const manyTopics = Array.from({ length: 12 }, (_, i) => `topic-${i + 1}`);
const h = setup([
dreamEntry({ status: 'completed', touchedTopics: manyTopics }),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
// First 8 visible.
expect(f).toContain('topic-1');
expect(f).toContain('topic-8');
// Past the cap — must NOT be inlined.
expect(f).not.toContain('topic-9');
expect(f).not.toContain('topic-12');
// Tail summary.
expect(f).toContain('+4 more');
// Header still reflects the full count, not the capped slice.
expect(f).toContain('Topics touched (12)');
});
});
});
Loading
Loading