mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-24 21:10:43 -07:00
feat(cli): add deep work completion summary stats
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user