mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(cli): add deep work completion summary stats
This commit is contained in:
@@ -190,6 +190,43 @@ describe('ToolResultDisplay', () => {
|
|||||||
expect(output).toMatchSnapshot();
|
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', () => {
|
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(false);
|
mockUseAlternateBuffer.mockReturnValue(false);
|
||||||
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
|
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
|||||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||||
import { theme } from '../../semantic-colors.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 { useUIState } from '../../contexts/UIStateContext.js';
|
||||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||||
@@ -42,6 +46,49 @@ interface FileDiffResult {
|
|||||||
fileName: string;
|
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> = ({
|
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
@@ -115,6 +162,81 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
return null;
|
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
|
// 2. High-performance path: Virtualized ANSI in interactive mode
|
||||||
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
|
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
|
||||||
// If availableHeight is undefined, fallback to a safe default to prevents infinite loop
|
// 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,
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import type { Config } from '../config/config.js';
|
|||||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||||
import { saveDeepWorkState } from '../services/deepWorkState.js';
|
import { saveDeepWorkState } from '../services/deepWorkState.js';
|
||||||
import { StartDeepWorkRunTool } from './deep-work-tools.js';
|
import {
|
||||||
|
StartDeepWorkRunTool,
|
||||||
|
StopDeepWorkRunTool,
|
||||||
|
} from './deep-work-tools.js';
|
||||||
|
|
||||||
describe('StartDeepWorkRunTool', () => {
|
describe('StartDeepWorkRunTool', () => {
|
||||||
let tempRootDir: string;
|
let tempRootDir: string;
|
||||||
@@ -84,4 +87,84 @@ describe('StartDeepWorkRunTool', () => {
|
|||||||
`Plan context: ${persistedPlanPath}.`,
|
`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,
|
BaseDeclarativeTool,
|
||||||
BaseToolInvocation,
|
BaseToolInvocation,
|
||||||
Kind,
|
Kind,
|
||||||
|
type DeepWorkSummaryDisplay,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
|
type ToolResultDisplay,
|
||||||
type ToolInvocation,
|
type ToolInvocation,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
@@ -64,7 +66,7 @@ export interface StopDeepWorkRunParams {
|
|||||||
|
|
||||||
function createSuccessResult(
|
function createSuccessResult(
|
||||||
llmContent: string,
|
llmContent: string,
|
||||||
returnDisplay: string,
|
returnDisplay: ToolResultDisplay,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
): ToolResult {
|
): ToolResult {
|
||||||
return {
|
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 {
|
function createErrorResult(message: string, type: ToolErrorType): ToolResult {
|
||||||
return {
|
return {
|
||||||
llmContent: message,
|
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 {
|
function syncApprovedPlanPath(config: Config, state: DeepWorkState): void {
|
||||||
const configPath = config.getApprovedPlanPath()?.trim();
|
const configPath = config.getApprovedPlanPath()?.trim();
|
||||||
const statePath = state.approvedPlanPath?.trim();
|
const statePath = state.approvedPlanPath?.trim();
|
||||||
@@ -617,16 +665,18 @@ class StopDeepWorkRunInvocation extends BaseToolInvocation<
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.params.reason?.trim()) {
|
const reason = this.params.reason?.trim();
|
||||||
state.rejectionReason = this.params.reason.trim();
|
if (reason) {
|
||||||
|
state.rejectionReason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveDeepWorkState(this.config, state);
|
await saveDeepWorkState(this.config, state);
|
||||||
|
const summary = buildDeepWorkSummaryDisplay(state, reason);
|
||||||
|
|
||||||
return createSuccessResult(
|
return createSuccessResult(
|
||||||
JSON.stringify({ state: summarizeState(state) }),
|
JSON.stringify({ state: summarizeState(state), summary }),
|
||||||
`Deep Work status set to ${state.status}.`,
|
summary,
|
||||||
{ state },
|
{ state, summary },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,7 +664,35 @@ export interface TodoList {
|
|||||||
todos: Todo[];
|
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';
|
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user