diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 797e405b62..fa1883958f 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -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( + , + ); + 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 diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 61f1540017..c9831f6aca 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -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 = ({ resultDisplay, availableTerminalHeight, @@ -115,6 +162,81 @@ export const ToolResultDisplay: React.FC = ({ 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 ( + + + Deep Work Summary + + + Status: + + {summary.status.toUpperCase()} + + + + Executions: + + {summary.executionCount}/{summary.maxRuns} ({executionBudgetUsed}% + budget used) + + + + Runtime: + + {formatDuration(summary.elapsedSeconds)} / {summary.maxTimeMinutes}m + + + + Required Context: + + {summary.answeredRequiredQuestions}/{summary.totalRequiredQuestions} + + + + Readiness: + + {summary.readinessVerdict ?? 'n/a'} + + + {summary.completionPromise && ( + + Completion Signal: + {summary.completionPromise} + + )} + {summary.approvedPlanPath && ( + + Plan Context: + {summary.approvedPlanPath} + + )} + {summary.reason && ( + + Reason: + {summary.reason} + + )} + + Run ID: + {summary.runId} + + + ); + } + // 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 diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 57d8dec3a8..f6bb87c03b 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -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 { diff --git a/packages/core/src/tools/deep-work-tools.test.ts b/packages/core/src/tools/deep-work-tools.test.ts index a29f4862e0..cc41182745 100644 --- a/packages/core/src/tools/deep-work-tools.test.ts +++ b/packages/core/src/tools/deep-work-tools.test.ts @@ -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); + }); }); diff --git a/packages/core/src/tools/deep-work-tools.ts b/packages/core/src/tools/deep-work-tools.ts index d1fac695ba..625970d8bb 100644 --- a/packages/core/src/tools/deep-work-tools.ts +++ b/packages/core/src/tools/deep-work-tools.ts @@ -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, ): 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 { }; } +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 }, ); } } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index dc974a5ff7..6beb21ee01 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -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';