diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts index e84157780..8e33b7ae4 100644 --- a/packages/cli/src/services/insight/generators/DataProcessor.ts +++ b/packages/cli/src/services/insight/generators/DataProcessor.ts @@ -14,6 +14,7 @@ import type { HeatMapData, StreakData, SessionFacets, + InsightProgressCallback, } from '../types/StaticInsightTypes.js'; import type { QualitativeInsights, @@ -262,19 +263,29 @@ export class DataProcessor { async generateInsights( baseDir: string, facetsOutputDir?: string, + onProgress?: InsightProgressCallback, ): Promise { + if (onProgress) onProgress('Scanning chat files', 0); const allChatFiles = await this.scanChatFiles(baseDir); - const [metrics, facets] = await Promise.all([ - this.generateMetrics(allChatFiles), - this.generateFacets(allChatFiles, facetsOutputDir), - ]); + if (onProgress) onProgress('Generating metrics', 10); + const metrics = await this.generateMetrics(allChatFiles); + if (onProgress) onProgress('Analyzing sessions', 20); + const facets = await this.generateFacets( + allChatFiles, + facetsOutputDir, + onProgress, + ); + + if (onProgress) onProgress('Generating qualitative insights', 80); const qualitative = await this.generateQualitativeInsights(metrics, facets); // Aggregate satisfaction and friction data from facets const { satisfactionAgg, frictionAgg } = this.aggregateFacetsData(facets); + if (onProgress) onProgress('Finalizing report', 100); + return { ...metrics, qualitative, @@ -901,6 +912,7 @@ None captured`; private async generateFacets( allFiles: Array<{ path: string; mtime: number }>, facetsOutputDir?: string, + onProgress?: InsightProgressCallback, ): Promise { // Sort files by recency (descending) and take top 50 const recentFiles = [...allFiles] @@ -912,6 +924,9 @@ None captured`; // Create a limit function with concurrency of 4 to avoid 429 errors const limit = pLimit(4); + let completed = 0; + const total = recentFiles.length; + // Analyze sessions concurrently with limit const analysisPromises = recentFiles.map((fileInfo) => limit(async () => { @@ -933,6 +948,15 @@ None captured`; 'utf-8', ); const existingFacet = JSON.parse(existingData); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress( + 'Analyzing sessions', + percent, + `${completed}/${total}`, + ); + } return existingFacet; } catch (readError) { // File doesn't exist or is invalid, proceed to analyze @@ -967,9 +991,20 @@ None captured`; } } + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + return facet; } catch (e) { console.error(`Error analyzing session file ${fileInfo.path}:`, e); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } return null; } }), diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts index b6e538084..bd98331e4 100644 --- a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -9,7 +9,10 @@ import path from 'path'; import os from 'os'; import { DataProcessor } from './DataProcessor.js'; import { TemplateRenderer } from './TemplateRenderer.js'; -import type { InsightData } from '../types/StaticInsightTypes.js'; +import type { + InsightData, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -30,7 +33,10 @@ export class StaticInsightGenerator { } // Generate the static insight HTML file - async generateStaticInsight(baseDir: string): Promise { + async generateStaticInsight( + baseDir: string, + onProgress?: InsightProgressCallback, + ): Promise { try { // Ensure output directory exists const outputDir = await this.ensureOutputDirectory(); @@ -42,6 +48,7 @@ export class StaticInsightGenerator { const insights: InsightData = await this.dataProcessor.generateInsights( baseDir, facetsDir, + onProgress, ); // Render HTML diff --git a/packages/cli/src/services/insight/types/StaticInsightTypes.ts b/packages/cli/src/services/insight/types/StaticInsightTypes.ts index 2b66357c0..392b3fabe 100644 --- a/packages/cli/src/services/insight/types/StaticInsightTypes.ts +++ b/packages/cli/src/services/insight/types/StaticInsightTypes.ts @@ -79,3 +79,9 @@ export interface StaticInsightTemplateData { scripts: string; generatedTime: string; } + +export type InsightProgressCallback = ( + stage: string, + progress: number, + detail?: string, +) => void; diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 04a0497e1..7e293dba4 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -7,6 +7,7 @@ import type { CommandContext, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; +import type { HistoryItemInsightProgress } from '../types.js'; import { t } from '../../i18n/index.js'; import { join } from 'path'; import os from 'os'; @@ -67,17 +68,33 @@ export const insightCommand: SlashCommand = { context.services.config, ); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Processing your chat history...'), - }, - Date.now(), - ); + const updateProgress = ( + stage: string, + progress: number, + detail?: string, + ) => { + const progressItem: HistoryItemInsightProgress = { + type: MessageType.INSIGHT_PROGRESS, + progress: { + stage, + progress, + detail, + }, + }; + context.ui.setPendingItem(progressItem); + }; + + // Initial progress + updateProgress(t('Starting insight generation...'), 0); // Generate the static insight HTML file - const outputPath = - await insightGenerator.generateStaticInsight(projectsDir); + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + updateProgress, + ); + + // Clear pending item + context.ui.setPendingItem(null); context.ui.addItem( { @@ -119,6 +136,9 @@ export const insightCommand: SlashCommand = { context.ui.setDebugMessage(t('Insights ready.')); } catch (error) { + // Clear pending item on error + context.ui.setPendingItem(null); + context.ui.addItem( { type: MessageType.ERROR, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a4fa9ee7c..1b8046ac0 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -33,6 +33,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; +import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -176,6 +177,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'mcp_status' && ( )} + {itemForDisplay.type === 'insight_progress' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx new file mode 100644 index 000000000..7f1933a21 --- /dev/null +++ b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { InsightProgressProps } from '../../types.js'; +import Spinner from 'ink-spinner'; + +interface InsightProgressMessageProps { + progress: InsightProgressProps; +} + +export const InsightProgressMessage: React.FC = ({ + progress, +}) => { + const { stage, progress: percent, detail, isComplete, error } = progress; + const width = 30; + const completedWidth = Math.round((percent / 100) * width); + const remainingWidth = width - completedWidth; + + const bar = + '█'.repeat(Math.max(0, completedWidth)) + + '░'.repeat(Math.max(0, remainingWidth)); + + if (error) { + return ( + + ✕ {stage} + {error} + + ); + } + + if (isComplete) { + return ( + + ✓ {stage} + + ); + } + + return ( + + + + + + {stage} + + + + {bar} {Math.round(percent)}% + + + {detail && ( + + {detail} + + )} + + ); +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59ff06bcf..2595aa99b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -181,10 +181,21 @@ export const useSlashCommandProcessor = ( type: 'summary', summary: message.summary, }; - } else { + } else if (message.type === MessageType.INSIGHT_PROGRESS) { historyItemContent = { - type: message.type, - text: message.content, + type: 'insight_progress', + progress: message.progress, + }; + } else { + // At this point message should be of type { type: INFO|ERROR|USER, content: string } + // We cast to be sure or check existence of content + const msg = message as { + type: MessageType.INFO | MessageType.ERROR | MessageType.USER; + content: string; + }; + historyItemContent = { + type: msg.type, + text: msg.content, }; } addItem(historyItemContent, message.timestamp.getTime()); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index b111f9ac7..399bcfaef 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -251,6 +251,11 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showTips: boolean; }; +export type HistoryItemInsightProgress = HistoryItemBase & { + type: 'insight_progress'; + progress: InsightProgressProps; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -278,7 +283,8 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList - | HistoryItemMcpStatus; + | HistoryItemMcpStatus + | HistoryItemInsightProgress; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -301,6 +307,15 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', + INSIGHT_PROGRESS = 'insight_progress', +} + +export interface InsightProgressProps { + stage: string; + progress: number; + detail?: string; + isComplete?: boolean; + error?: string; } // Simplified message structure for internal feedback @@ -367,6 +382,11 @@ export type Message = type: MessageType.SUMMARY; summary: SummaryProps; timestamp: Date; + } + | { + type: MessageType.INSIGHT_PROGRESS; + progress: InsightProgressProps; + timestamp: Date; }; export interface ConsoleMessageItem {