feat(cli): add deep work completion summary stats

This commit is contained in:
Dmitry Lyalin
2026-02-12 23:36:31 -05:00
parent c57b55fa03
commit 5ab0c1e31b
6 changed files with 350 additions and 9 deletions

View File

@@ -190,6 +190,43 @@ describe('ToolResultDisplay', () => {
expect(output).toMatchSnapshot();
});
it('renders detailed deep work summary display', () => {
const deepWorkSummary = {
type: 'deep_work_summary' as const,
status: 'completed' as const,
runId: 'deep-work-run-42',
executionCount: 4,
maxRuns: 8,
elapsedSeconds: 372,
maxTimeMinutes: 90,
answeredRequiredQuestions: 3,
totalRequiredQuestions: 4,
readinessVerdict: 'ready' as const,
completionPromise: 'MIGRATION_DONE',
approvedPlanPath: '/tmp/plans/deep-work-auth.md',
reason: 'All acceptance criteria completed.',
};
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={deepWorkSummary}
terminalWidth={100}
availableTerminalHeight={24}
/>,
);
const output = lastFrame() || '';
expect(output).toContain('Deep Work Summary');
expect(output).toContain('COMPLETED');
expect(output).toContain('Executions: 4/8');
expect(output).toContain('Runtime: 6m 12s / 90m');
expect(output).toContain('Required Context: 3/4');
expect(output).toContain('Readiness: ready');
expect(output).toContain('Completion Signal: MIGRATION_DONE');
expect(output).toContain('Plan Context: /tmp/plans/deep-work-auth.md');
expect(output).toContain('Reason: All acceptance criteria completed.');
expect(output).toContain('Run ID: deep-work-run-42');
});
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', () => {
mockUseAlternateBuffer.mockReturnValue(false);
// availableHeight calculation: 20 - 1 - 5 = 14 > 3

View File

@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
import type {
AnsiOutput,
AnsiLine,
DeepWorkSummaryDisplay,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -42,6 +46,49 @@ interface FileDiffResult {
fileName: string;
}
function isDeepWorkSummaryDisplay(
value: unknown,
): value is DeepWorkSummaryDisplay {
return (
!!value &&
typeof value === 'object' &&
'type' in value &&
value.type === 'deep_work_summary'
);
}
function formatDuration(seconds: number | null): string {
if (seconds === null) {
return 'n/a';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${remainingSeconds}s`;
}
function getDeepWorkStatusColor(
status: DeepWorkSummaryDisplay['status'],
): string {
switch (status) {
case 'completed':
return theme.status.success;
case 'rejected':
return theme.status.error;
case 'paused':
case 'stopped':
return theme.status.warning;
default:
return theme.text.accent;
}
}
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
resultDisplay,
availableTerminalHeight,
@@ -115,6 +162,81 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return null;
}
if (
typeof truncatedResultDisplay === 'object' &&
isDeepWorkSummaryDisplay(truncatedResultDisplay)
) {
const summary = truncatedResultDisplay;
const executionBudgetUsed =
summary.maxRuns > 0
? Math.min(
100,
Math.round((summary.executionCount / summary.maxRuns) * 100),
)
: 0;
return (
<Box width={childWidth} flexDirection="column">
<Text color={theme.text.accent} bold>
Deep Work Summary
</Text>
<Text>
<Text color={theme.text.secondary}>Status: </Text>
<Text color={getDeepWorkStatusColor(summary.status)} bold>
{summary.status.toUpperCase()}
</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>Executions: </Text>
<Text color={theme.text.primary}>
{summary.executionCount}/{summary.maxRuns} ({executionBudgetUsed}%
budget used)
</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>Runtime: </Text>
<Text color={theme.text.primary}>
{formatDuration(summary.elapsedSeconds)} / {summary.maxTimeMinutes}m
</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>Required Context: </Text>
<Text color={theme.text.primary}>
{summary.answeredRequiredQuestions}/{summary.totalRequiredQuestions}
</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>Readiness: </Text>
<Text color={theme.text.primary}>
{summary.readinessVerdict ?? 'n/a'}
</Text>
</Text>
{summary.completionPromise && (
<Text>
<Text color={theme.text.secondary}>Completion Signal: </Text>
<Text color={theme.text.primary}>{summary.completionPromise}</Text>
</Text>
)}
{summary.approvedPlanPath && (
<Text wrap="wrap">
<Text color={theme.text.secondary}>Plan Context: </Text>
<Text color={theme.text.primary}>{summary.approvedPlanPath}</Text>
</Text>
)}
{summary.reason && (
<Text wrap="wrap">
<Text color={theme.text.secondary}>Reason: </Text>
<Text color={theme.text.primary}>{summary.reason}</Text>
</Text>
)}
<Text>
<Text color={theme.text.secondary}>Run ID: </Text>
<Text color={theme.text.primary}>{summary.runId}</Text>
</Text>
</Box>
);
}
// 2. High-performance path: Virtualized ANSI in interactive mode
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
// If availableHeight is undefined, fallback to a safe default to prevents infinite loop

View File

@@ -1167,6 +1167,27 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
newText: toolResult.returnDisplay.newContent,
};
}
if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'deep_work_summary'
) {
const summary = toolResult.returnDisplay;
const elapsed =
typeof summary.elapsedSeconds === 'number'
? `${summary.elapsedSeconds}s`
: 'n/a';
const text = [
`Deep Work summary (${summary.status})`,
`Executions: ${summary.executionCount}/${summary.maxRuns}`,
`Required context: ${summary.answeredRequiredQuestions}/${summary.totalRequiredQuestions}`,
`Elapsed: ${elapsed} / ${summary.maxTimeMinutes}m`,
`Readiness: ${summary.readinessVerdict ?? 'n/a'}`,
].join('\n');
return {
type: 'content',
content: { type: 'text', text },
};
}
return null;
}
} else {

View File

@@ -12,7 +12,10 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { saveDeepWorkState } from '../services/deepWorkState.js';
import { StartDeepWorkRunTool } from './deep-work-tools.js';
import {
StartDeepWorkRunTool,
StopDeepWorkRunTool,
} from './deep-work-tools.js';
describe('StartDeepWorkRunTool', () => {
let tempRootDir: string;
@@ -84,4 +87,84 @@ describe('StartDeepWorkRunTool', () => {
`Plan context: ${persistedPlanPath}.`,
);
});
it('returns detailed Deep Work summary stats when run is completed', async () => {
const now = new Date();
const startedAt = new Date(now.getTime() - 125_000).toISOString();
await saveDeepWorkState(mockConfig as Config, {
runId: 'deep-work-run-2',
status: 'running',
prompt: 'Implement and verify multi-step auth migration.',
approvedPlanPath: '/tmp/deep-plan.md',
maxRuns: 8,
maxTimeMinutes: 90,
completionPromise: 'AUTH_MIGRATION_DONE',
requiredQuestions: [
{
id: 'scope',
question: 'What is the migration scope?',
required: true,
answer: 'Auth + session layer',
done: true,
updatedAt: startedAt,
},
{
id: 'fallback',
question: 'Rollback strategy?',
required: true,
answer: '',
done: false,
updatedAt: startedAt,
},
],
iteration: 3,
createdAt: startedAt,
startedAt,
lastUpdatedAt: startedAt,
rejectionReason: null,
readinessReport: {
verdict: 'ready',
missingRequiredQuestionIds: [],
followUpQuestions: [],
blockingReasons: [],
singleShotRecommendation: false,
reviewer: 'heuristic',
generatedAt: startedAt,
},
});
const tool = new StopDeepWorkRunTool(
mockConfig as Config,
mockMessageBus as unknown as MessageBus,
);
const result = await tool
.build({ mode: 'completed' })
.execute(new AbortController().signal);
expect(typeof result.returnDisplay).toBe('object');
if (
typeof result.returnDisplay !== 'object' ||
!result.returnDisplay ||
!('type' in result.returnDisplay) ||
result.returnDisplay.type !== 'deep_work_summary'
) {
return;
}
expect(result.returnDisplay).toMatchObject({
type: 'deep_work_summary',
status: 'completed',
runId: 'deep-work-run-2',
executionCount: 3,
maxRuns: 8,
maxTimeMinutes: 90,
answeredRequiredQuestions: 1,
totalRequiredQuestions: 2,
readinessVerdict: 'ready',
completionPromise: 'AUTH_MIGRATION_DONE',
approvedPlanPath: '/tmp/deep-plan.md',
});
expect(result.returnDisplay.elapsedSeconds).toBeGreaterThan(0);
});
});

View File

@@ -8,7 +8,9 @@ import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type DeepWorkSummaryDisplay,
type ToolResult,
type ToolResultDisplay,
type ToolInvocation,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -64,7 +66,7 @@ export interface StopDeepWorkRunParams {
function createSuccessResult(
llmContent: string,
returnDisplay: string,
returnDisplay: ToolResultDisplay,
data?: Record<string, unknown>,
): ToolResult {
return {
@@ -74,6 +76,23 @@ function createSuccessResult(
};
}
function getElapsedSeconds(
startedAt: string | null,
endedAt: string,
): number | null {
if (!startedAt) {
return null;
}
const startMs = Date.parse(startedAt);
const endMs = Date.parse(endedAt);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
return null;
}
return Math.max(0, Math.round((endMs - startMs) / 1000));
}
function createErrorResult(message: string, type: ToolErrorType): ToolResult {
return {
llmContent: message,
@@ -103,6 +122,35 @@ function summarizeState(state: DeepWorkState): Record<string, unknown> {
};
}
function buildDeepWorkSummaryDisplay(
state: DeepWorkState,
reason?: string,
): DeepWorkSummaryDisplay {
const endedAt = new Date().toISOString();
const totalRequiredQuestions = state.requiredQuestions.filter(
(q) => q.required,
).length;
const answeredRequiredQuestions = state.requiredQuestions.filter(
(q) => q.required && q.answer.trim().length > 0,
).length;
return {
type: 'deep_work_summary',
status: state.status,
runId: state.runId,
executionCount: state.iteration,
maxRuns: state.maxRuns,
elapsedSeconds: getElapsedSeconds(state.startedAt, endedAt),
maxTimeMinutes: state.maxTimeMinutes,
answeredRequiredQuestions,
totalRequiredQuestions,
readinessVerdict: state.readinessReport?.verdict ?? null,
completionPromise: state.completionPromise,
approvedPlanPath: state.approvedPlanPath,
reason: reason ?? null,
};
}
function syncApprovedPlanPath(config: Config, state: DeepWorkState): void {
const configPath = config.getApprovedPlanPath()?.trim();
const statePath = state.approvedPlanPath?.trim();
@@ -617,16 +665,18 @@ class StopDeepWorkRunInvocation extends BaseToolInvocation<
break;
}
if (this.params.reason?.trim()) {
state.rejectionReason = this.params.reason.trim();
const reason = this.params.reason?.trim();
if (reason) {
state.rejectionReason = reason;
}
await saveDeepWorkState(this.config, state);
const summary = buildDeepWorkSummaryDisplay(state, reason);
return createSuccessResult(
JSON.stringify({ state: summarizeState(state) }),
`Deep Work status set to ${state.status}.`,
{ state },
JSON.stringify({ state: summarizeState(state), summary }),
summary,
{ state, summary },
);
}
}

View File

@@ -664,7 +664,35 @@ export interface TodoList {
todos: Todo[];
}
export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList;
export interface DeepWorkSummaryDisplay {
type: 'deep_work_summary';
status:
| 'configured'
| 'ready'
| 'running'
| 'paused'
| 'stopped'
| 'completed'
| 'rejected';
runId: string;
executionCount: number;
maxRuns: number;
elapsedSeconds: number | null;
maxTimeMinutes: number;
answeredRequiredQuestions: number;
totalRequiredQuestions: number;
readinessVerdict?: 'ready' | 'needs_answers' | 'reject' | null;
completionPromise?: string | null;
approvedPlanPath?: string | null;
reason?: string | null;
}
export type ToolResultDisplay =
| string
| FileDiff
| AnsiOutput
| TodoList
| DeepWorkSummaryDisplay;
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';