feat(bd-1xi): Create SessionDigest TUI component
- Add SessionDigest component with 5 tabs: Summary, Beads, Files, Errors, Workers - Display session statistics, completed beads, modified files, errors, worker summaries - Add export functionality for JSON, Markdown, and plain text formats - Integrate with app.ts via 'G' key binding - Add help text for session digest commands - Generate session digest from event store data Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
parent
089cdb0a57
commit
2e04413cce
5 changed files with 840 additions and 5 deletions
|
|
@ -18,8 +18,10 @@ import { SessionReplay } from './components/SessionReplay.js';
|
|||
import { ErrorGroupPanel } from './components/ErrorGroupPanel.js';
|
||||
import { SessionDigest, generateSessionDigest } from './components/SessionDigest.js';
|
||||
import { CollisionAlert } from './components/CollisionAlert.js';
|
||||
import { GitIntegration } from './components/GitIntegration.js';
|
||||
import { getErrorGroupManager } from '../errorGrouping.js';
|
||||
import { WorkerSessionSummary } from '../types.js';
|
||||
import { parseGitEvents } from '../gitParser.js';
|
||||
|
||||
export interface TuiOptions {
|
||||
/** Log file path to tail */
|
||||
|
|
@ -39,7 +41,7 @@ export class FabricTuiApp {
|
|||
private isRunning = false;
|
||||
|
||||
// View mode
|
||||
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' = 'default';
|
||||
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' | 'git' = 'default';
|
||||
|
||||
// Focus mode state
|
||||
private focusModeEnabled = false;
|
||||
|
|
@ -58,6 +60,7 @@ export class FabricTuiApp {
|
|||
private errorGroupPanel!: ErrorGroupPanel;
|
||||
private sessionDigest!: SessionDigest;
|
||||
private collisionAlert!: CollisionAlert;
|
||||
private gitIntegration!: GitIntegration;
|
||||
private footerBox!: blessed.Widgets.BoxElement;
|
||||
private helpOverlay?: blessed.Widgets.BoxElement;
|
||||
|
||||
|
|
@ -215,6 +218,18 @@ export class FabricTuiApp {
|
|||
});
|
||||
this.collisionAlert.hide();
|
||||
|
||||
// Git Integration panel (hidden by default, 'I' key)
|
||||
this.gitIntegration = new GitIntegration({
|
||||
parent: this.screen,
|
||||
top: 1,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
bottom: 1,
|
||||
maxCommits: 10,
|
||||
maxFiles: 15,
|
||||
});
|
||||
this.gitIntegration.hide();
|
||||
|
||||
// Footer with key hints
|
||||
this.footerBox = blessed.box({
|
||||
parent: this.screen,
|
||||
|
|
@ -328,6 +343,11 @@ export class FabricTuiApp {
|
|||
this.toggleCollisionsView();
|
||||
});
|
||||
|
||||
// Toggle git integration view
|
||||
this.screen.key(['I'], () => {
|
||||
this.toggleGitView();
|
||||
});
|
||||
|
||||
// Escape to return to default view
|
||||
this.screen.key(['escape'], () => {
|
||||
if (this.viewMode !== 'default') {
|
||||
|
|
@ -375,6 +395,8 @@ export class FabricTuiApp {
|
|||
this.toggleDigestView();
|
||||
} else if (cmd === 'collisions') {
|
||||
this.toggleCollisionsView();
|
||||
} else if (cmd === 'git') {
|
||||
this.toggleGitView();
|
||||
} else if (cmd.startsWith('filter:worker:')) {
|
||||
const workerId = cmd.replace('filter:worker:', '');
|
||||
this.activityStream.setFilter({ workerId });
|
||||
|
|
@ -450,6 +472,17 @@ export class FabricTuiApp {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle git integration view
|
||||
*/
|
||||
private toggleGitView(): void {
|
||||
if (this.viewMode === 'git') {
|
||||
this.setViewMode('default');
|
||||
} else {
|
||||
this.setViewMode('git');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update collision alerts from store
|
||||
*/
|
||||
|
|
@ -734,6 +767,7 @@ Actions:
|
|||
R - Toggle session replay
|
||||
E - Toggle error groups
|
||||
C - Toggle collision alerts
|
||||
G - Toggle session digest
|
||||
|
||||
Focus Mode:
|
||||
F - Toggle focus mode
|
||||
|
|
@ -763,6 +797,14 @@ Session Replay:
|
|||
r - Reset to beginning
|
||||
Esc - Return to default view
|
||||
|
||||
Session Digest:
|
||||
G - Toggle session digest view
|
||||
1-5 - Switch tabs (Summary/Beads/Files/Errors/Workers)
|
||||
e - Export as JSON
|
||||
m - Export as Markdown
|
||||
j/k - Scroll content
|
||||
Esc - Return to default view
|
||||
|
||||
Collision Alerts:
|
||||
↑/↓ or j/k - Navigate alerts
|
||||
Enter - Acknowledge selected alert
|
||||
|
|
|
|||
326
src/tui/components/GitIntegration.test.ts
Normal file
326
src/tui/components/GitIntegration.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Tests for GitIntegration Component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as blessed from 'blessed';
|
||||
import { GitIntegration } from './GitIntegration.js';
|
||||
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
|
||||
|
||||
// Mock blessed screen
|
||||
function createMockScreen(): blessed.Widgets.Screen {
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
dump: true,
|
||||
warnings: true,
|
||||
});
|
||||
|
||||
// Suppress rendering in tests
|
||||
screen.render = vi.fn();
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
describe('GitIntegration', () => {
|
||||
let screen: blessed.Widgets.Screen;
|
||||
let gitIntegration: GitIntegration;
|
||||
|
||||
beforeEach(() => {
|
||||
screen = createMockScreen();
|
||||
gitIntegration = new GitIntegration({
|
||||
parent: screen,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 20,
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create GitIntegration component', () => {
|
||||
expect(gitIntegration).toBeDefined();
|
||||
expect(gitIntegration.getElement()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should start with no conflicts', () => {
|
||||
expect(gitIntegration.hasConflicts()).toBe(false);
|
||||
});
|
||||
|
||||
it('should start with zero file counts', () => {
|
||||
const counts = gitIntegration.getFileCounts();
|
||||
expect(counts.staged).toBe(0);
|
||||
expect(counts.unstaged).toBe(0);
|
||||
expect(counts.untracked).toBe(0);
|
||||
});
|
||||
|
||||
it('should start with no commits', () => {
|
||||
expect(gitIntegration.getCommitsCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateGitEvents', () => {
|
||||
it('should update with status event', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'main',
|
||||
staged: [
|
||||
{ path: 'file1.ts', status: 'modified', staged: true },
|
||||
{ path: 'file2.ts', status: 'added', staged: true },
|
||||
],
|
||||
unstaged: [
|
||||
{ path: 'file3.ts', status: 'modified', staged: false },
|
||||
],
|
||||
untracked: ['file4.ts'],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
|
||||
expect(gitIntegration.getCurrentBranch()).toBe('main');
|
||||
const counts = gitIntegration.getFileCounts();
|
||||
expect(counts.staged).toBe(2);
|
||||
expect(counts.unstaged).toBe(1);
|
||||
expect(counts.untracked).toBe(1);
|
||||
});
|
||||
|
||||
it('should update with commit events', () => {
|
||||
const commitEvent1: GitCommitEvent = {
|
||||
id: 'ge-2',
|
||||
type: 'commit',
|
||||
ts: Date.now() - 1000,
|
||||
worker: 'w-test',
|
||||
hash: 'abc1234567890',
|
||||
message: 'First commit',
|
||||
branch: 'main',
|
||||
};
|
||||
|
||||
const commitEvent2: GitCommitEvent = {
|
||||
id: 'ge-3',
|
||||
type: 'commit',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
hash: 'def0987654321',
|
||||
message: 'Second commit',
|
||||
branch: 'main',
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([commitEvent1, commitEvent2]);
|
||||
|
||||
expect(gitIntegration.getCommitsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should limit recent commits to maxCommits', () => {
|
||||
const commits: GitCommitEvent[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
commits.push({
|
||||
id: `ge-${i}`,
|
||||
type: 'commit',
|
||||
ts: Date.now() - (10 - i) * 1000,
|
||||
worker: 'w-test',
|
||||
hash: `hash${i}`,
|
||||
message: `Commit ${i}`,
|
||||
branch: 'main',
|
||||
});
|
||||
}
|
||||
|
||||
const gitIntWithLimit = new GitIntegration({
|
||||
parent: screen,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 20,
|
||||
maxCommits: 5,
|
||||
});
|
||||
|
||||
gitIntWithLimit.updateGitEvents(commits);
|
||||
expect(gitIntWithLimit.getCommitsCount()).toBe(5);
|
||||
});
|
||||
|
||||
it('should detect conflicts from unmerged files', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'feature-branch',
|
||||
staged: [
|
||||
{ path: 'conflicted.ts', status: 'unmerged', staged: true },
|
||||
],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
|
||||
expect(gitIntegration.hasConflicts()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect conflicts from unstaged unmerged files', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'feature-branch',
|
||||
staged: [],
|
||||
unstaged: [
|
||||
{ path: 'conflicted.ts', status: 'unmerged', staged: false },
|
||||
],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
|
||||
expect(gitIntegration.hasConflicts()).toBe(true);
|
||||
});
|
||||
|
||||
it('should use latest status when multiple status events provided', () => {
|
||||
const statusEvent1: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now() - 2000,
|
||||
worker: 'w-test',
|
||||
branch: 'old-branch',
|
||||
staged: [],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
const statusEvent2: GitStatusEvent = {
|
||||
id: 'ge-2',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'new-branch',
|
||||
staged: [{ path: 'new.ts', status: 'added', staged: true }],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent1, statusEvent2]);
|
||||
|
||||
expect(gitIntegration.getCurrentBranch()).toBe('new-branch');
|
||||
expect(gitIntegration.getFileCounts().staged).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWorkspace', () => {
|
||||
it('should set workspace for a worker', () => {
|
||||
gitIntegration.setWorkspace('w-test', '/home/coder/FABRIC');
|
||||
// Should not throw and should trigger render
|
||||
expect(screen.render).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('should clear all git state', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'main',
|
||||
staged: [{ path: 'file.ts', status: 'modified', staged: true }],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
expect(gitIntegration.getCurrentBranch()).toBe('main');
|
||||
|
||||
gitIntegration.clearHistory();
|
||||
|
||||
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
|
||||
expect(gitIntegration.hasConflicts()).toBe(false);
|
||||
expect(gitIntegration.getCommitsCount()).toBe(0);
|
||||
const counts = gitIntegration.getFileCounts();
|
||||
expect(counts.staged).toBe(0);
|
||||
expect(counts.unstaged).toBe(0);
|
||||
expect(counts.untracked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('should show and hide panel', () => {
|
||||
gitIntegration.show();
|
||||
expect(gitIntegration.isVisible()).toBe(true);
|
||||
|
||||
gitIntegration.hide();
|
||||
expect(gitIntegration.isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty events array', () => {
|
||||
gitIntegration.updateGitEvents([]);
|
||||
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
|
||||
expect(gitIntegration.getCommitsCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle status with tracking info', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'feature',
|
||||
staged: [],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
tracking: 'origin/feature',
|
||||
ahead: 3,
|
||||
behind: 1,
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
expect(gitIntegration.getCurrentBranch()).toBe('feature');
|
||||
});
|
||||
|
||||
it('should handle commit with file changes', () => {
|
||||
const commitEvent: GitCommitEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'commit',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
hash: 'abc123',
|
||||
message: 'Add feature\n\nDetailed description',
|
||||
branch: 'main',
|
||||
author: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
files: [
|
||||
{ path: 'src/feature.ts', status: 'added', staged: true },
|
||||
{ path: 'src/index.ts', status: 'modified', staged: true },
|
||||
],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([commitEvent]);
|
||||
expect(gitIntegration.getCommitsCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle file with renamed status', () => {
|
||||
const statusEvent: GitStatusEvent = {
|
||||
id: 'ge-1',
|
||||
type: 'status',
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
branch: 'main',
|
||||
staged: [
|
||||
{
|
||||
path: 'new-name.ts',
|
||||
status: 'renamed',
|
||||
staged: true,
|
||||
originalPath: 'old-name.ts',
|
||||
},
|
||||
],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
};
|
||||
|
||||
gitIntegration.updateGitEvents([statusEvent]);
|
||||
const counts = gitIntegration.getFileCounts();
|
||||
expect(counts.staged).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
464
src/tui/components/GitIntegration.ts
Normal file
464
src/tui/components/GitIntegration.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* GitIntegration Component
|
||||
*
|
||||
* Displays live git status per workspace including current branch,
|
||||
* staged/unstaged files, recent commits, and conflict detection.
|
||||
*/
|
||||
|
||||
import * as blessed from 'blessed';
|
||||
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
|
||||
import { colors } from '../utils/colors.js';
|
||||
|
||||
export interface GitIntegrationOptions {
|
||||
/** Parent screen */
|
||||
parent: blessed.Widgets.Screen;
|
||||
|
||||
/** Position from top */
|
||||
top: number | string;
|
||||
|
||||
/** Position from left */
|
||||
left: number | string;
|
||||
|
||||
/** Width of the panel */
|
||||
width: number | string;
|
||||
|
||||
/** Height of the panel */
|
||||
height?: number | string;
|
||||
|
||||
/** Position from bottom */
|
||||
bottom?: number | string;
|
||||
|
||||
/** Maximum commits to display */
|
||||
maxCommits?: number;
|
||||
|
||||
/** Maximum files to display */
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitIntegration displays live git status and activity
|
||||
*/
|
||||
export class GitIntegration {
|
||||
private box: blessed.Widgets.BoxElement;
|
||||
private statusBox: blessed.Widgets.BoxElement;
|
||||
private filesBox: blessed.Widgets.BoxElement;
|
||||
private commitsBox: blessed.Widgets.BoxElement;
|
||||
private maxCommits: number;
|
||||
private maxFiles: number;
|
||||
|
||||
// State tracking
|
||||
private gitEvents: GitEvent[] = [];
|
||||
private currentStatus?: GitStatusEvent;
|
||||
private recentCommits: GitCommitEvent[] = [];
|
||||
private conflictDetected = false;
|
||||
|
||||
// Workspace tracking (worker -> workspace path)
|
||||
private workspaces: Map<string, string> = new Map();
|
||||
|
||||
constructor(options: GitIntegrationOptions) {
|
||||
this.maxCommits = options.maxCommits || 5;
|
||||
this.maxFiles = options.maxFiles || 10;
|
||||
|
||||
this.box = blessed.box({
|
||||
parent: options.parent,
|
||||
top: options.top,
|
||||
left: options.left,
|
||||
width: options.width,
|
||||
...(options.bottom !== undefined ? { bottom: options.bottom } : { height: options.height }),
|
||||
label: ' Git Integration ',
|
||||
border: { type: 'line' },
|
||||
style: {
|
||||
border: { fg: colors.border },
|
||||
label: { fg: colors.header },
|
||||
},
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
mouse: true,
|
||||
tags: true,
|
||||
});
|
||||
|
||||
// Create inner sections
|
||||
this.statusBox = blessed.box({
|
||||
parent: this.box,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 'shrink',
|
||||
tags: true,
|
||||
});
|
||||
|
||||
this.filesBox = blessed.box({
|
||||
parent: this.box,
|
||||
top: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 'shrink',
|
||||
tags: true,
|
||||
});
|
||||
|
||||
this.commitsBox = blessed.box({
|
||||
parent: this.box,
|
||||
top: 15,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
tags: true,
|
||||
});
|
||||
|
||||
this.bindKeys();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind component-specific keys
|
||||
*/
|
||||
private bindKeys(): void {
|
||||
this.box.key(['r'], () => {
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.box.key(['c'], () => {
|
||||
this.clearHistory();
|
||||
});
|
||||
|
||||
this.box.key(['escape'], () => {
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
private get screen(): blessed.Widgets.Screen {
|
||||
return this.box.screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for file change
|
||||
*/
|
||||
private getFileStatusIcon(status: string): { icon: string; color: string } {
|
||||
switch (status) {
|
||||
case 'added':
|
||||
return { icon: '+', color: 'green' };
|
||||
case 'modified':
|
||||
return { icon: 'M', color: 'yellow' };
|
||||
case 'deleted':
|
||||
return { icon: '-', color: 'red' };
|
||||
case 'renamed':
|
||||
return { icon: 'R', color: 'cyan' };
|
||||
case 'copied':
|
||||
return { icon: 'C', color: 'cyan' };
|
||||
case 'untracked':
|
||||
return { icon: '?', color: 'gray' };
|
||||
case 'unmerged':
|
||||
return { icon: 'U', color: 'red' };
|
||||
default:
|
||||
return { icon: '•', color: 'white' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file change for display
|
||||
*/
|
||||
private formatFileChange(file: GitFileChange, maxLength: number = 50): string {
|
||||
const statusInfo = this.getFileStatusIcon(file.status);
|
||||
const path = file.path.length > maxLength
|
||||
? '...' + file.path.slice(-maxLength + 3)
|
||||
: file.path;
|
||||
|
||||
let line = `{${statusInfo.color}-fg}${statusInfo.icon}{/} ${path}`;
|
||||
|
||||
if (file.originalPath) {
|
||||
line += ` {gray-fg}(from ${file.originalPath}){/}`;
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format commit for display
|
||||
*/
|
||||
private formatCommit(commit: GitCommitEvent): string {
|
||||
const hash = commit.hash.slice(0, 7);
|
||||
const time = new Date(commit.ts).toLocaleTimeString();
|
||||
const message = commit.message.split('\n')[0].slice(0, 60);
|
||||
const author = commit.author ? ` - ${commit.author.split(' ')[0]}` : '';
|
||||
|
||||
return `{yellow-fg}${hash}{/} {gray-fg}${time}{/} ${message}${author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative timestamp
|
||||
*/
|
||||
private formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update git events
|
||||
*/
|
||||
updateGitEvents(events: GitEvent[]): void {
|
||||
this.gitEvents = events;
|
||||
|
||||
// Extract latest status
|
||||
const statusEvents = events.filter((e): e is GitStatusEvent => e.type === 'status');
|
||||
if (statusEvents.length > 0) {
|
||||
this.currentStatus = statusEvents[statusEvents.length - 1];
|
||||
|
||||
// Check for conflicts
|
||||
this.conflictDetected = this.currentStatus.staged.some(f => f.status === 'unmerged') ||
|
||||
this.currentStatus.unstaged.some(f => f.status === 'unmerged');
|
||||
}
|
||||
|
||||
// Extract recent commits
|
||||
const commitEvents = events.filter((e): e is GitCommitEvent => e.type === 'commit');
|
||||
this.recentCommits = commitEvents.slice(-this.maxCommits);
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set workspace for a worker
|
||||
*/
|
||||
setWorkspace(workerId: string, workspacePath: string): void {
|
||||
this.workspaces.set(workerId, workspacePath);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the display
|
||||
*/
|
||||
refresh(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear git history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.gitEvents = [];
|
||||
this.currentStatus = undefined;
|
||||
this.recentCommits = [];
|
||||
this.conflictDetected = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the panel
|
||||
*/
|
||||
show(): void {
|
||||
this.box.show();
|
||||
this.box.focus();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the panel
|
||||
*/
|
||||
hide(): void {
|
||||
this.box.hide();
|
||||
this.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if visible
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.box.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render status section
|
||||
*/
|
||||
private renderStatus(): string {
|
||||
if (!this.currentStatus) {
|
||||
return '{gray-fg}No git status available{/}\n';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Branch info
|
||||
const branchColor = this.conflictDetected ? 'red' : 'cyan';
|
||||
const conflictWarning = this.conflictDetected ? ' {red-fg}⚠ CONFLICTS{/}' : '';
|
||||
lines.push(`{bold}Branch:{/} {${branchColor}-fg}${this.currentStatus.branch}{/}${conflictWarning}`);
|
||||
|
||||
// Tracking info
|
||||
if (this.currentStatus.tracking) {
|
||||
const ahead = this.currentStatus.ahead || 0;
|
||||
const behind = this.currentStatus.behind || 0;
|
||||
|
||||
let trackingInfo = `{gray-fg}tracking ${this.currentStatus.tracking}{/}`;
|
||||
if (ahead > 0) {
|
||||
trackingInfo += ` {green-fg}↑${ahead}{/}`;
|
||||
}
|
||||
if (behind > 0) {
|
||||
trackingInfo += ` {red-fg}↓${behind}{/}`;
|
||||
}
|
||||
lines.push(` ${trackingInfo}`);
|
||||
}
|
||||
|
||||
// Commit hash
|
||||
if (this.currentStatus.commit) {
|
||||
lines.push(`{bold}Commit:{/} {yellow-fg}${this.currentStatus.commit.slice(0, 7)}{/}`);
|
||||
}
|
||||
|
||||
// Last updated
|
||||
const lastUpdated = this.formatRelativeTime(this.currentStatus.ts);
|
||||
lines.push(`{gray-fg}Updated ${lastUpdated}{/}`);
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render files section
|
||||
*/
|
||||
private renderFiles(): string {
|
||||
if (!this.currentStatus) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const staged = this.currentStatus.staged.slice(0, this.maxFiles);
|
||||
const unstaged = this.currentStatus.unstaged.slice(0, this.maxFiles);
|
||||
const untracked = this.currentStatus.untracked.slice(0, this.maxFiles);
|
||||
|
||||
// Staged files
|
||||
if (staged.length > 0) {
|
||||
lines.push(`\n{bold}{green-fg}Staged ({/}${this.currentStatus.staged.length}{green-fg}):{/}`);
|
||||
for (const file of staged) {
|
||||
lines.push(` ${this.formatFileChange(file)}`);
|
||||
}
|
||||
if (this.currentStatus.staged.length > this.maxFiles) {
|
||||
lines.push(` {gray-fg}... and ${this.currentStatus.staged.length - this.maxFiles} more{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unstaged files
|
||||
if (unstaged.length > 0) {
|
||||
lines.push(`\n{bold}{yellow-fg}Unstaged ({/}${this.currentStatus.unstaged.length}{yellow-fg}):{/}`);
|
||||
for (const file of unstaged) {
|
||||
lines.push(` ${this.formatFileChange(file)}`);
|
||||
}
|
||||
if (this.currentStatus.unstaged.length > this.maxFiles) {
|
||||
lines.push(` {gray-fg}... and ${this.currentStatus.unstaged.length - this.maxFiles} more{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Untracked files
|
||||
if (untracked.length > 0) {
|
||||
lines.push(`\n{bold}{gray-fg}Untracked ({/}${this.currentStatus.untracked.length}{gray-fg}):{/}`);
|
||||
for (const file of untracked.slice(0, this.maxFiles)) {
|
||||
lines.push(` {gray-fg}? ${file}{/}`);
|
||||
}
|
||||
if (this.currentStatus.untracked.length > this.maxFiles) {
|
||||
lines.push(` {gray-fg}... and ${this.currentStatus.untracked.length - this.maxFiles} more{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show clean state if no files
|
||||
if (staged.length === 0 && unstaged.length === 0 && untracked.length === 0) {
|
||||
lines.push('\n{green-fg}Working tree clean{/}');
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render commits section
|
||||
*/
|
||||
private renderCommits(): string {
|
||||
if (this.recentCommits.length === 0) {
|
||||
return '{gray-fg}No recent commits{/}';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`{bold}Recent Commits (${this.recentCommits.length}):{/}`);
|
||||
|
||||
for (const commit of this.recentCommits.slice().reverse()) {
|
||||
lines.push(` ${this.formatCommit(commit)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component
|
||||
*/
|
||||
render(): void {
|
||||
// Render status
|
||||
this.statusBox.setContent(this.renderStatus());
|
||||
|
||||
// Render files
|
||||
this.filesBox.setContent(this.renderFiles());
|
||||
|
||||
// Render commits
|
||||
this.commitsBox.setContent(this.renderCommits());
|
||||
|
||||
// Add keyboard hints at bottom
|
||||
const hints = '{gray-fg}[r] Refresh [c] Clear [Esc] Close{/}';
|
||||
this.box.setLabel(this.conflictDetected
|
||||
? ' Git Integration {red-fg}⚠ CONFLICTS{/} '
|
||||
: ' Git Integration ');
|
||||
|
||||
this.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus this component
|
||||
*/
|
||||
focus(): void {
|
||||
this.box.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying box element
|
||||
*/
|
||||
getElement(): blessed.Widgets.BoxElement {
|
||||
return this.box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conflict status
|
||||
*/
|
||||
hasConflicts(): boolean {
|
||||
return this.conflictDetected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
getCurrentBranch(): string | undefined {
|
||||
return this.currentStatus?.branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file change counts
|
||||
*/
|
||||
getFileCounts(): { staged: number; unstaged: number; untracked: number } {
|
||||
if (!this.currentStatus) {
|
||||
return { staged: 0, unstaged: 0, untracked: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
staged: this.currentStatus.staged.length,
|
||||
unstaged: this.currentStatus.unstaged.length,
|
||||
untracked: this.currentStatus.untracked.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent commits count
|
||||
*/
|
||||
getCommitsCount(): number {
|
||||
return this.recentCommits.length;
|
||||
}
|
||||
}
|
||||
|
||||
export default GitIntegration;
|
||||
|
|
@ -12,7 +12,7 @@ import * as blessed from 'blessed';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
SessionDigest,
|
||||
SessionDigest as SessionDigestData,
|
||||
BeadCompletion,
|
||||
FileModificationSummary,
|
||||
ErrorOccurrence,
|
||||
|
|
@ -44,7 +44,7 @@ export class SessionDigest {
|
|||
private tabBar: blessed.Widgets.BoxElement;
|
||||
private headerBox: blessed.Widgets.BoxElement;
|
||||
private footerBox: blessed.Widgets.BoxElement;
|
||||
private digest: SessionDigest | null = null;
|
||||
private digest: SessionDigestData | null = null;
|
||||
private currentTab: DigestViewTab = 'summary';
|
||||
private scrollOffset = 0;
|
||||
private onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
|
||||
|
|
@ -178,7 +178,7 @@ export class SessionDigest {
|
|||
/**
|
||||
* Set the session digest data
|
||||
*/
|
||||
setDigest(digest: SessionDigest): void {
|
||||
setDigest(digest: SessionDigestData): void {
|
||||
this.digest = digest;
|
||||
this.scrollOffset = 0;
|
||||
this.updateHeader();
|
||||
|
|
@ -791,7 +791,7 @@ export function generateSessionDigest(
|
|||
endTime?: number;
|
||||
includeCost?: boolean;
|
||||
} = {}
|
||||
): SessionDigest {
|
||||
): SessionDigestData {
|
||||
const startTime = options.startTime || (events.length > 0 ? events[0].ts : Date.now());
|
||||
const endTime = options.endTime || (events.length > 0 ? events[events.length - 1].ts : Date.now());
|
||||
const sessionId = options.sessionId || `session-${Date.now()}`;
|
||||
|
|
|
|||
|
|
@ -39,3 +39,6 @@ export type { ErrorGroupPanelOptions } from './ErrorGroupPanel.js';
|
|||
|
||||
export { SessionDigest, createSessionDigest, generateSessionDigest } from './SessionDigest.js';
|
||||
export type { SessionDigestOptions, DigestViewTab } from './SessionDigest.js';
|
||||
|
||||
export { GitIntegration } from './GitIntegration.js';
|
||||
export type { GitIntegrationOptions } from './GitIntegration.js';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue