Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 458a45ac91 |
@@ -47,6 +47,12 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.3.0 - Activity widgets and transcript-driven telemetry
|
||||
|
||||
- **🏃 New Activity widgets** - Added **All Activity**, **Tools Activity**, **Agents Activity**, and **Todo Progress** widgets for live workflow visibility.
|
||||
- **🧠 Transcript activity parsing** - Parses `tool_use`/`tool_result`, `Task` subagent calls, and todo updates (`TodoWrite`, `TaskCreate`, `TaskUpdate`) from Claude transcript JSONL.
|
||||
- **📏 Activity widget width control** - Activity widgets support per-widget max-width editing with **(w)**, including clearing truncation.
|
||||
|
||||
### v2.2.9 - v2.2.11 - GitLab support, reset timers, context, compaction, and git widgets
|
||||
|
||||
- **🦊 GitLab PR/MR support** - `Git Branch` and `Git PR/MR` now support GitHub, GitLab, and compatible self-hosted remotes, using `gh` or `glab` as appropriate.
|
||||
|
||||
@@ -28,6 +28,13 @@ bun run example
|
||||
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`.
|
||||
- **Session Clock** / **Session Cost** - Show elapsed session time and the current session cost in USD.
|
||||
|
||||
### Activity
|
||||
|
||||
- **All Activity** - Show a compact running-first summary across tools, agents, and todos, with completion counters.
|
||||
- **Tools Activity** - Show currently running and recently completed tool operations.
|
||||
- **Agents Activity** - Show running and recently completed Claude subagent tasks.
|
||||
- **Todo Progress** - Show in-progress todo focus and completion ratio from transcript tasks.
|
||||
|
||||
### Git
|
||||
|
||||
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. Works with GitHub (`gh`) and GitLab (`glab`); for self-hosted hosts whose name contains neither token, whichever CLI is authenticated against that host (`gh auth status --hostname <h>` / `glab auth status --hostname <h>`) is used.
|
||||
@@ -167,6 +174,7 @@ Widget-specific shortcuts:
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
|
||||
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
|
||||
- **All Activity / Tools Activity / Agents Activity / Todo Progress**: `w` edit max width (set `0` or blank to disable truncation)
|
||||
- **Custom Text / Custom Symbol**: `e` edit text or symbol
|
||||
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
|
||||
- **Link**: `u` URL, `e` link text
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type { RenderContext } from './types/RenderContext';
|
||||
import type { StatusJSON } from './types/StatusJSON';
|
||||
import { StatusJSONSchema } from './types/StatusJSON';
|
||||
import { getActivitySnapshot } from './utils/activity';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
@@ -50,6 +51,17 @@ function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
|
||||
}
|
||||
|
||||
const ACTIVITY_WIDGET_TYPES = new Set([
|
||||
'tools-activity',
|
||||
'agents-activity',
|
||||
'todo-progress',
|
||||
'activity'
|
||||
]);
|
||||
|
||||
function hasActivityWidgets(lines: { type?: string }[][]): boolean {
|
||||
return lines.some(line => line.some(item => typeof item.type === 'string' && ACTIVITY_WIDGET_TYPES.has(item.type)));
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
|
||||
if (process.stdin.isTTY) {
|
||||
@@ -167,6 +179,10 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
}
|
||||
}
|
||||
|
||||
const activity = hasActivityWidgets(lines)
|
||||
? getActivitySnapshot(data.transcript_path)
|
||||
: null;
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
@@ -177,6 +193,7 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
sessionDuration,
|
||||
skillsMetrics,
|
||||
compactionData: hasCompactionWidget ? { count: compactionCount } : null,
|
||||
activity,
|
||||
isPreview: false,
|
||||
minimalist: settings.minimalistMode
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ActivityToolStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
export type ActivityAgentStatus = 'running' | 'completed';
|
||||
|
||||
export type ActivityTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface ActivityToolEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
target?: string;
|
||||
status: ActivityToolStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityAgentEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
model?: string;
|
||||
description?: string;
|
||||
status: ActivityAgentStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityTodoItem {
|
||||
content: string;
|
||||
status: ActivityTodoStatus;
|
||||
}
|
||||
|
||||
export interface ActivitySnapshot {
|
||||
tools: ActivityToolEntry[];
|
||||
agents: ActivityAgentEntry[];
|
||||
todos: ActivityTodoItem[];
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ActivityParseOptions {
|
||||
maxTools?: number;
|
||||
maxAgents?: number;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SkillsMetrics
|
||||
} from '../types';
|
||||
|
||||
import type { ActivitySnapshot } from './Activity';
|
||||
import type { SpeedMetrics } from './SpeedMetrics';
|
||||
import type { StatusJSON } from './StatusJSON';
|
||||
import type { TokenMetrics } from './TokenMetrics';
|
||||
@@ -31,6 +32,7 @@ export interface RenderContext {
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
compactionData?: CompactionData | null;
|
||||
activity?: ActivitySnapshot | null;
|
||||
terminalWidth?: number | null;
|
||||
isPreview?: boolean;
|
||||
minimalist?: boolean;
|
||||
|
||||
@@ -20,3 +20,13 @@ export type { ColorEntry } from './ColorEntry';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { SpeedMetrics } from './SpeedMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
export type {
|
||||
ActivityAgentEntry,
|
||||
ActivityAgentStatus,
|
||||
ActivityParseOptions,
|
||||
ActivitySnapshot,
|
||||
ActivityTodoItem,
|
||||
ActivityTodoStatus,
|
||||
ActivityToolEntry,
|
||||
ActivityToolStatus
|
||||
} from './Activity';
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearActivitySnapshotCache,
|
||||
getActivitySnapshot,
|
||||
parseActivityContent
|
||||
} from '../activity';
|
||||
|
||||
function makeToolUseLine(id: string, name: string, input: Record<string, unknown>, timestamp: string): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResultLine(toolUseId: string, timestamp: string, isError = false): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
is_error: isError
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('activity parsing', () => {
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
it('parses tool, agent, and todo activity from transcript content', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/src/auth.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolResultLine('tool-1', '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'explore', model: 'haiku', description: 'Find auth flow' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolResultLine('agent-1', '2026-03-03T00:00:05.000Z'),
|
||||
makeToolUseLine('todo-write-1', 'TodoWrite', {
|
||||
todos: [
|
||||
{ id: 'task-1', content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ id: 'task-2', content: 'Add tests', status: 'pending' }
|
||||
]
|
||||
}, '2026-03-03T00:00:06.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', {
|
||||
taskId: 'task-2',
|
||||
status: 'completed'
|
||||
}, '2026-03-03T00:00:07.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
|
||||
expect(snapshot.tools).toHaveLength(1);
|
||||
expect(snapshot.tools[0]).toMatchObject({
|
||||
id: 'tool-1',
|
||||
name: 'Read',
|
||||
target: '/repo/src/auth.ts',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.agents).toHaveLength(1);
|
||||
expect(snapshot.agents[0]).toMatchObject({
|
||||
id: 'agent-1',
|
||||
type: 'explore',
|
||||
model: 'haiku',
|
||||
description: 'Find auth flow',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ content: 'Add tests', status: 'completed' }
|
||||
]);
|
||||
expect(snapshot.updatedAt?.toISOString()).toBe('2026-03-03T00:00:07.000Z');
|
||||
});
|
||||
|
||||
it('handles malformed lines and unknown todo statuses safely', () => {
|
||||
const content = [
|
||||
'not-json',
|
||||
makeToolUseLine('todo-create-1', 'TaskCreate', { subject: 'Draft proposal', status: 'not_started' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', { taskId: '1', status: 'unknown-status' }, '2026-03-03T00:00:01.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Draft proposal', status: 'pending' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects configured caps for tools and agents', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/1.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('tool-2', 'Read', { file_path: '/repo/2.ts' }, '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('tool-3', 'Read', { file_path: '/repo/3.ts' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'a' }, '2026-03-03T00:00:03.000Z'),
|
||||
makeToolUseLine('agent-2', 'Task', { subagent_type: 'b' }, '2026-03-03T00:00:04.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content, { maxTools: 2, maxAgents: 1 });
|
||||
expect(snapshot.tools.map(tool => tool.id)).toEqual(['tool-2', 'tool-3']);
|
||||
expect(snapshot.agents.map(agent => agent.id)).toEqual(['agent-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity cache', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-activity-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('uses cached snapshot when transcript file metadata is unchanged', () => {
|
||||
const line = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${line}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools).toHaveLength(1);
|
||||
expect(second.tools).toHaveLength(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('re-parses when transcript metadata changes', () => {
|
||||
const firstLine = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const secondLine = makeToolUseLine('tool-2', 'Edit', { file_path: '/repo/longer-path-name-b.ts' }, '2026-03-03T00:01:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${firstLine}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
fs.writeFileSync(transcriptPath, `${secondLine}\n`, 'utf-8');
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools[0]?.id).toBe('tool-1');
|
||||
expect(second.tools[0]?.id).toBe('tool-2');
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -96,10 +96,12 @@ describe('widget catalog', () => {
|
||||
expect(categories).toContain('Tokens');
|
||||
expect(categories).toContain('Token Speed');
|
||||
expect(categories).toContain('Session');
|
||||
expect(categories).toContain('Activity');
|
||||
expect(categories).toContain('Usage');
|
||||
expect(categories).toContain('Environment');
|
||||
expect(categories).toContain('Custom');
|
||||
expect(categories).toContain('Layout');
|
||||
expect(categories.indexOf('Activity')).toBe(categories.indexOf('Session') + 1);
|
||||
});
|
||||
|
||||
it('returns runtime widget instances for non-layout widget types', () => {
|
||||
@@ -220,6 +222,11 @@ describe('widget catalog filtering', () => {
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps all activity at the top within the Activity category', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'Activity', '');
|
||||
expect(results[0]?.type).toBe('activity');
|
||||
});
|
||||
|
||||
it('prioritizes name match before type and description matches', () => {
|
||||
const rankingCatalog: WidgetCatalogEntry[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
ActivityAgentEntry,
|
||||
ActivityParseOptions,
|
||||
ActivitySnapshot,
|
||||
ActivityTodoItem,
|
||||
ActivityTodoStatus,
|
||||
ActivityToolEntry
|
||||
} from '../types/Activity';
|
||||
|
||||
const DEFAULT_MAX_TOOLS = 20;
|
||||
const DEFAULT_MAX_AGENTS = 10;
|
||||
|
||||
interface TranscriptLine {
|
||||
timestamp?: string;
|
||||
message?: { content?: TranscriptContent[] };
|
||||
}
|
||||
|
||||
interface TranscriptContent {
|
||||
type?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
interface ActivityCacheEntry {
|
||||
path: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
snapshot: ActivitySnapshot;
|
||||
}
|
||||
|
||||
let activityCacheEntry: ActivityCacheEntry | null = null;
|
||||
|
||||
function createEmptySnapshot(): ActivitySnapshot {
|
||||
return {
|
||||
tools: [],
|
||||
agents: [],
|
||||
todos: [],
|
||||
updatedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function parseDate(timestamp: unknown): Date | null {
|
||||
if (typeof timestamp !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
function extractToolTarget(name: string, input?: Record<string, unknown>): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'Edit': {
|
||||
const filePath = input.file_path;
|
||||
if (typeof filePath === 'string' && filePath.length > 0) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const pathValue = input.path;
|
||||
if (typeof pathValue === 'string' && pathValue.length > 0) {
|
||||
return pathValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'Glob':
|
||||
case 'Grep': {
|
||||
const pattern = input.pattern;
|
||||
return typeof pattern === 'string' && pattern.length > 0 ? pattern : undefined;
|
||||
}
|
||||
case 'Bash': {
|
||||
const command = input.command;
|
||||
if (typeof command !== 'string' || command.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return truncate(command, 30);
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTodoStatus(status: unknown): ActivityTodoStatus | null {
|
||||
if (typeof status !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'not_started':
|
||||
return 'pending';
|
||||
case 'in_progress':
|
||||
case 'running':
|
||||
return 'in_progress';
|
||||
case 'completed':
|
||||
case 'complete':
|
||||
case 'done':
|
||||
return 'completed';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskIdentifier(input: Record<string, unknown>, fallbackId: string): string {
|
||||
const rawTaskId = input.taskId;
|
||||
if (typeof rawTaskId === 'string' || typeof rawTaskId === 'number') {
|
||||
return String(rawTaskId);
|
||||
}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function resolveTaskIndex(
|
||||
taskId: unknown,
|
||||
taskIdToIndex: Map<string, number>,
|
||||
todos: ActivityTodoItem[]
|
||||
): number | null {
|
||||
if (typeof taskId === 'string' || typeof taskId === 'number') {
|
||||
const taskKey = String(taskId);
|
||||
const mappedIndex = taskIdToIndex.get(taskKey);
|
||||
if (typeof mappedIndex === 'number') {
|
||||
return mappedIndex;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(taskKey)) {
|
||||
const oneBasedIndex = Number.parseInt(taskKey, 10);
|
||||
const zeroBasedIndex = oneBasedIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < todos.length) {
|
||||
return zeroBasedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyTodoWrite(input: Record<string, unknown>, todos: ActivityTodoItem[], taskIdToIndex: Map<string, number>): void {
|
||||
const inputTodos = input.todos;
|
||||
if (!Array.isArray(inputTodos)) {
|
||||
return;
|
||||
}
|
||||
|
||||
todos.length = 0;
|
||||
taskIdToIndex.clear();
|
||||
|
||||
for (let index = 0; index < inputTodos.length; index++) {
|
||||
const todo = inputTodos[index] as unknown;
|
||||
if (!todo || typeof todo !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const todoRecord = todo as Record<string, unknown>;
|
||||
const content = typeof todoRecord.content === 'string' ? todoRecord.content : '';
|
||||
const normalizedStatus = normalizeTodoStatus(todoRecord.status) ?? 'pending';
|
||||
|
||||
todos.push({
|
||||
content: content || 'Untitled task',
|
||||
status: normalizedStatus
|
||||
});
|
||||
|
||||
const taskId = typeof todoRecord.id === 'string' || typeof todoRecord.id === 'number'
|
||||
? String(todoRecord.id)
|
||||
: String(index + 1);
|
||||
taskIdToIndex.set(taskId, todos.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTaskCreate(
|
||||
blockId: string,
|
||||
input: Record<string, unknown>,
|
||||
todos: ActivityTodoItem[],
|
||||
taskIdToIndex: Map<string, number>
|
||||
): void {
|
||||
const subject = typeof input.subject === 'string' ? input.subject : '';
|
||||
const description = typeof input.description === 'string' ? input.description : '';
|
||||
const content = subject || description || 'Untitled task';
|
||||
const status = normalizeTodoStatus(input.status) ?? 'pending';
|
||||
|
||||
todos.push({
|
||||
content,
|
||||
status
|
||||
});
|
||||
|
||||
taskIdToIndex.set(getTaskIdentifier(input, blockId), todos.length - 1);
|
||||
}
|
||||
|
||||
function applyTaskUpdate(
|
||||
input: Record<string, unknown>,
|
||||
todos: ActivityTodoItem[],
|
||||
taskIdToIndex: Map<string, number>
|
||||
): void {
|
||||
const index = resolveTaskIndex(input.taskId, taskIdToIndex, todos);
|
||||
if (index === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const todo = todos[index];
|
||||
if (!todo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = normalizeTodoStatus(input.status);
|
||||
if (status) {
|
||||
todo.status = status;
|
||||
}
|
||||
|
||||
const subject = typeof input.subject === 'string' ? input.subject : '';
|
||||
const description = typeof input.description === 'string' ? input.description : '';
|
||||
const content = subject || description;
|
||||
if (content) {
|
||||
todo.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseActivityContent(content: string, options: ActivityParseOptions = {}): ActivitySnapshot {
|
||||
const snapshot = createEmptySnapshot();
|
||||
const toolsById = new Map<string, ActivityToolEntry>();
|
||||
const agentsById = new Map<string, ActivityAgentEntry>();
|
||||
const todos: ActivityTodoItem[] = [];
|
||||
const taskIdToIndex = new Map<string, number>();
|
||||
const maxTools = options.maxTools ?? DEFAULT_MAX_TOOLS;
|
||||
const maxAgents = options.maxAgents ?? DEFAULT_MAX_AGENTS;
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: TranscriptLine;
|
||||
try {
|
||||
entry = JSON.parse(line) as TranscriptLine;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (entryTimestamp) {
|
||||
const previousTimestamp = snapshot.updatedAt;
|
||||
if (!previousTimestamp || entryTimestamp.getTime() > previousTimestamp.getTime()) {
|
||||
snapshot.updatedAt = entryTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = entry.message?.content;
|
||||
if (!Array.isArray(blocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = entryTimestamp ?? new Date();
|
||||
|
||||
for (const rawBlock of blocks as unknown[]) {
|
||||
if (!rawBlock || typeof rawBlock !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const block = rawBlock as TranscriptContent;
|
||||
|
||||
if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
if (block.name === 'Task') {
|
||||
const input = block.input ?? {};
|
||||
const agentEntry: ActivityAgentEntry = {
|
||||
id: block.id,
|
||||
type: typeof input.subagent_type === 'string' ? input.subagent_type : 'unknown',
|
||||
model: typeof input.model === 'string' ? input.model : undefined,
|
||||
description: typeof input.description === 'string' ? input.description : undefined,
|
||||
status: 'running',
|
||||
startTime: timestamp
|
||||
};
|
||||
agentsById.set(block.id, agentEntry);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TodoWrite') {
|
||||
applyTodoWrite(block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TaskCreate') {
|
||||
applyTaskCreate(block.id, block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TaskUpdate') {
|
||||
applyTaskUpdate(block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
toolsById.set(block.id, {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
target: extractToolTarget(block.name, block.input),
|
||||
status: 'running',
|
||||
startTime: timestamp
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
||||
const tool = toolsById.get(block.tool_use_id);
|
||||
if (tool) {
|
||||
tool.status = block.is_error ? 'error' : 'completed';
|
||||
tool.endTime = timestamp;
|
||||
}
|
||||
|
||||
const agent = agentsById.get(block.tool_use_id);
|
||||
if (agent) {
|
||||
agent.status = 'completed';
|
||||
agent.endTime = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.tools = Array.from(toolsById.values()).slice(-maxTools);
|
||||
snapshot.agents = Array.from(agentsById.values()).slice(-maxAgents);
|
||||
snapshot.todos = todos;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getActivitySnapshot(transcriptPath?: string, options: ActivityParseOptions = {}): ActivitySnapshot {
|
||||
if (!transcriptPath || transcriptPath.trim() === '') {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(transcriptPath);
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
} catch {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const cached = activityCacheEntry;
|
||||
if (
|
||||
cached?.path === resolvedPath
|
||||
&& cached.mtimeMs === stats.mtimeMs
|
||||
&& cached.size === stats.size
|
||||
) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(resolvedPath, 'utf-8');
|
||||
} catch {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const snapshot = parseActivityContent(content, options);
|
||||
activityCacheEntry = {
|
||||
path: resolvedPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
snapshot
|
||||
};
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function clearActivitySnapshotCache(): void {
|
||||
activityCacheEntry = null;
|
||||
}
|
||||
@@ -58,6 +58,10 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
|
||||
{ type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() },
|
||||
{ type: 'session-clock', create: () => new widgets.SessionClockWidget() },
|
||||
{ type: 'session-cost', create: () => new widgets.SessionCostWidget() },
|
||||
{ type: 'activity', create: () => new widgets.ActivityWidget() },
|
||||
{ type: 'tools-activity', create: () => new widgets.ToolsActivityWidget() },
|
||||
{ type: 'agents-activity', create: () => new widgets.AgentsActivityWidget() },
|
||||
{ type: 'todo-progress', create: () => new widgets.TodoProgressWidget() },
|
||||
{ type: 'block-timer', create: () => new widgets.BlockTimerWidget() },
|
||||
{ type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() },
|
||||
{ type: 'version', create: () => new widgets.VersionWidget() },
|
||||
|
||||
+15
-1
@@ -74,6 +74,17 @@ const layoutCatalogEntries = new Map<WidgetItemType, WidgetCatalogEntry>(
|
||||
])
|
||||
);
|
||||
|
||||
const WIDGET_SORT_PRIORITY: Partial<Record<WidgetItemType, number>> = { activity: 0 };
|
||||
|
||||
function getWidgetSortPriority(entry: WidgetCatalogEntry): number {
|
||||
// Only apply explicit ordering within the Activity category.
|
||||
if (entry.category !== 'Activity') {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
return WIDGET_SORT_PRIORITY[entry.type] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function getLayoutCatalogEntry(type: WidgetItemType): WidgetCatalogEntry | null {
|
||||
return layoutCatalogEntries.get(type) ?? null;
|
||||
}
|
||||
@@ -114,6 +125,7 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str
|
||||
const categoryFiltered = category === 'All'
|
||||
? [...catalog]
|
||||
: catalog.filter(entry => entry.category === category);
|
||||
const shouldPrioritizeActivity = category === 'Activity';
|
||||
|
||||
const records: FuzzySearchRecord<WidgetCatalogEntry>[] = categoryFiltered.map(entry => ({
|
||||
item: entry,
|
||||
@@ -121,7 +133,9 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
searchText: entry.searchText,
|
||||
sortText: entry.displayName,
|
||||
sortText: shouldPrioritizeActivity
|
||||
? `${String(getWidgetSortPriority(entry)).padStart(16, '0')} ${entry.displayName}`
|
||||
: entry.displayName,
|
||||
secondarySortText: entry.type
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
ActivityAgentEntry,
|
||||
ActivityTodoItem,
|
||||
ActivityToolEntry
|
||||
} from '../types/Activity';
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
import { ActivityWidthEditor } from './ActivityWidthEditor';
|
||||
import {
|
||||
applyMaxWidth,
|
||||
formatAgentLabel,
|
||||
formatElapsed,
|
||||
getMaxWidthModifier,
|
||||
getToolCountSummary,
|
||||
truncatePath,
|
||||
truncateText
|
||||
} from './activity-utils';
|
||||
|
||||
function formatRunningTool(tool: ActivityToolEntry): string {
|
||||
const target = tool.target ? `: ${truncatePath(tool.target, 20)}` : '';
|
||||
return `◐ ${tool.name}${target}`;
|
||||
}
|
||||
|
||||
function formatRunningAgent(agent: ActivityAgentEntry): string {
|
||||
return `◐ ${formatAgentLabel(agent, 24)} (${formatElapsed(agent.startTime, agent.endTime)})`;
|
||||
}
|
||||
|
||||
function formatTodoSegment(todos: ActivityTodoItem[]): string | null {
|
||||
if (todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inProgress = todos.find(todo => todo.status === 'in_progress');
|
||||
const completedCount = todos.filter(todo => todo.status === 'completed').length;
|
||||
|
||||
if (inProgress) {
|
||||
return `▸ ${truncateText(inProgress.content, 36)} (${completedCount}/${todos.length})`;
|
||||
}
|
||||
if (completedCount === todos.length) {
|
||||
return `✓ todos (${completedCount}/${todos.length})`;
|
||||
}
|
||||
|
||||
return `${completedCount}/${todos.length} todos`;
|
||||
}
|
||||
|
||||
export class ActivityWidget implements Widget {
|
||||
getDefaultColor(): string { return 'cyan'; }
|
||||
getDescription(): string { return 'Shows compact running-first Claude activity across tools, agents, and todos'; }
|
||||
getDisplayName(): string { return 'All Activity'; }
|
||||
getCategory(): string { return 'Activity'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const widthModifier = getMaxWidthModifier(item);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: widthModifier ? `(${widthModifier})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const activity = context.activity;
|
||||
if (context.isPreview) {
|
||||
const preview = '◐ Edit: auth.ts | ◐ explore [haiku] (42s) | ▸ Fix auth bug (2/5) | ✓T12 ✓A3 TD2/5';
|
||||
const output = item.rawValue ? preview : `All Activity: ${preview}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
if (!activity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tools = activity.tools;
|
||||
const agents = activity.agents;
|
||||
const todos = activity.todos;
|
||||
|
||||
const segments: string[] = [];
|
||||
|
||||
const runningTool = tools.filter(tool => tool.status === 'running').slice(-1)[0];
|
||||
if (runningTool) {
|
||||
segments.push(formatRunningTool(runningTool));
|
||||
}
|
||||
|
||||
const runningAgent = agents.filter(agent => agent.status === 'running').slice(-1)[0];
|
||||
if (runningAgent) {
|
||||
segments.push(formatRunningAgent(runningAgent));
|
||||
}
|
||||
|
||||
const todoSegment = formatTodoSegment(todos);
|
||||
if (todoSegment) {
|
||||
segments.push(todoSegment);
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
const completedToolSummary = getToolCountSummary(
|
||||
tools.filter(tool => tool.status === 'completed' || tool.status === 'error'),
|
||||
2
|
||||
);
|
||||
for (const [name, count] of completedToolSummary) {
|
||||
segments.push(`✓ ${name} ×${count}`);
|
||||
}
|
||||
|
||||
const latestCompletedAgent = agents.filter(agent => agent.status === 'completed').slice(-1)[0];
|
||||
if (latestCompletedAgent) {
|
||||
segments.push(`✓ ${latestCompletedAgent.type} (${formatElapsed(latestCompletedAgent.startTime, latestCompletedAgent.endTime)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const completedToolsCount = tools.filter(tool => tool.status === 'completed' || tool.status === 'error').length;
|
||||
const completedAgentsCount = agents.filter(agent => agent.status === 'completed').length;
|
||||
const completedTodosCount = todos.filter(todo => todo.status === 'completed').length;
|
||||
|
||||
const counters: string[] = [];
|
||||
if (completedToolsCount > 0) {
|
||||
counters.push(`✓T${completedToolsCount}`);
|
||||
}
|
||||
if (completedAgentsCount > 0) {
|
||||
counters.push(`✓A${completedAgentsCount}`);
|
||||
}
|
||||
if (todos.length > 0) {
|
||||
counters.push(`TD${completedTodosCount}/${todos.length}`);
|
||||
}
|
||||
if (counters.length > 0) {
|
||||
segments.push(counters.join(' '));
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rendered = segments.join(' | ');
|
||||
const output = item.rawValue ? rendered : `All Activity: ${rendered}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'w', label: '(w)idth', action: 'edit-width' }
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
||||
return React.createElement(ActivityWidthEditor, props);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { WidgetEditorProps } from '../types/Widget';
|
||||
import { shouldInsertInput } from '../utils/input-guards';
|
||||
|
||||
export const ActivityWidthEditor: React.FC<WidgetEditorProps> = ({ widget, onComplete, onCancel, action }) => {
|
||||
const [widthInput, setWidthInput] = useState(widget.maxWidth?.toString() ?? '');
|
||||
|
||||
useInput((input, key) => {
|
||||
if (action !== 'edit-width') {
|
||||
if (key.escape || key.return) {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const width = parseInt(widthInput, 10);
|
||||
if (!Number.isNaN(width) && width > 0) {
|
||||
onComplete({ ...widget, maxWidth: width });
|
||||
} else {
|
||||
const { maxWidth, ...rest } = widget;
|
||||
void maxWidth;
|
||||
onComplete(rest);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
} else if (key.backspace) {
|
||||
setWidthInput(widthInput.slice(0, -1));
|
||||
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
|
||||
setWidthInput(widthInput + input);
|
||||
}
|
||||
});
|
||||
|
||||
if (action !== 'edit-width') {
|
||||
return <Text>Unknown editor mode</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Box>
|
||||
<Text>Enter max width (blank or 0 for no limit): </Text>
|
||||
<Text>{widthInput}</Text>
|
||||
<Text backgroundColor='gray' color='black'>{' '}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Press Enter to save, ESC to cancel</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
import { ActivityWidthEditor } from './ActivityWidthEditor';
|
||||
import {
|
||||
applyMaxWidth,
|
||||
formatAgentLabel,
|
||||
formatElapsed,
|
||||
getMaxWidthModifier
|
||||
} from './activity-utils';
|
||||
|
||||
export class AgentsActivityWidget implements Widget {
|
||||
getDefaultColor(): string { return 'magenta'; }
|
||||
getDescription(): string { return 'Shows running and recently completed Claude subagent activity'; }
|
||||
getDisplayName(): string { return 'Agents Activity'; }
|
||||
getCategory(): string { return 'Activity'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const widthModifier = getMaxWidthModifier(item);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: widthModifier ? `(${widthModifier})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const agents = context.activity?.agents ?? [];
|
||||
if (context.isPreview) {
|
||||
const preview = '◐ explore [haiku]: tracing auth flow (42s) | ✓ reviewer (1m 8s)';
|
||||
const output = item.rawValue ? preview : `Agents: ${preview}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runningAgents = agents.filter(agent => agent.status === 'running');
|
||||
const recentCompletedAgents = agents.filter(agent => agent.status === 'completed').slice(-2);
|
||||
const selectedAgents = [...runningAgents, ...recentCompletedAgents].slice(-3);
|
||||
|
||||
if (selectedAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = selectedAgents.map((agent) => {
|
||||
const icon = agent.status === 'running' ? '◐' : '✓';
|
||||
const label = formatAgentLabel(agent, 28);
|
||||
const elapsed = formatElapsed(agent.startTime, agent.endTime);
|
||||
return `${icon} ${label} (${elapsed})`;
|
||||
});
|
||||
|
||||
const rendered = parts.join(' | ');
|
||||
const output = item.rawValue ? rendered : `Agents: ${rendered}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'w', label: '(w)idth', action: 'edit-width' }
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
||||
return React.createElement(ActivityWidthEditor, props);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
import { ActivityWidthEditor } from './ActivityWidthEditor';
|
||||
import {
|
||||
applyMaxWidth,
|
||||
getMaxWidthModifier,
|
||||
truncateText
|
||||
} from './activity-utils';
|
||||
|
||||
export class TodoProgressWidget implements Widget {
|
||||
getDefaultColor(): string { return 'green'; }
|
||||
getDescription(): string { return 'Shows in-progress Claude todo item and completion progress'; }
|
||||
getDisplayName(): string { return 'Todo Progress'; }
|
||||
getCategory(): string { return 'Activity'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const widthModifier = getMaxWidthModifier(item);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: widthModifier ? `(${widthModifier})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const todos = context.activity?.todos ?? [];
|
||||
if (context.isPreview) {
|
||||
const preview = '▸ Fix auth bug (2/5)';
|
||||
const output = item.rawValue ? preview : `Todo: ${preview}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
if (todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inProgress = todos.find(todo => todo.status === 'in_progress');
|
||||
const completedCount = todos.filter(todo => todo.status === 'completed').length;
|
||||
const totalCount = todos.length;
|
||||
|
||||
let rendered: string | null = null;
|
||||
|
||||
if (inProgress) {
|
||||
rendered = `▸ ${truncateText(inProgress.content, 50)} (${completedCount}/${totalCount})`;
|
||||
} else if (completedCount === totalCount && totalCount > 0) {
|
||||
rendered = `✓ All complete (${completedCount}/${totalCount})`;
|
||||
}
|
||||
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = item.rawValue ? rendered : `Todo: ${rendered}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'w', label: '(w)idth', action: 'edit-width' }
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
||||
return React.createElement(ActivityWidthEditor, props);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
import { ActivityWidthEditor } from './ActivityWidthEditor';
|
||||
import {
|
||||
applyMaxWidth,
|
||||
getMaxWidthModifier,
|
||||
getToolCountSummary,
|
||||
truncatePath
|
||||
} from './activity-utils';
|
||||
|
||||
export class ToolsActivityWidget implements Widget {
|
||||
getDefaultColor(): string { return 'yellow'; }
|
||||
getDescription(): string { return 'Shows running and recently completed Claude tool activity'; }
|
||||
getDisplayName(): string { return 'Tools Activity'; }
|
||||
getCategory(): string { return 'Activity'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const widthModifier = getMaxWidthModifier(item);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: widthModifier ? `(${widthModifier})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const tools = context.activity?.tools ?? [];
|
||||
if (context.isPreview) {
|
||||
const preview = '◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2';
|
||||
const output = item.rawValue ? preview : `Tools: ${preview}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
if (tools.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const runningTools = tools.filter(tool => tool.status === 'running').slice(-2);
|
||||
const completedTools = tools.filter(tool => tool.status === 'completed' || tool.status === 'error');
|
||||
|
||||
for (const tool of runningTools) {
|
||||
const target = tool.target ? `: ${truncatePath(tool.target, 20)}` : '';
|
||||
parts.push(`◐ ${tool.name}${target}`);
|
||||
}
|
||||
|
||||
for (const [name, count] of getToolCountSummary(completedTools, 3)) {
|
||||
parts.push(`✓ ${name} ×${count}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rendered = parts.join(' | ');
|
||||
const output = item.rawValue ? rendered : `Tools: ${rendered}`;
|
||||
return applyMaxWidth(output, item);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'w', label: '(w)idth', action: 'edit-width' }
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
||||
return React.createElement(ActivityWidthEditor, props);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { ActivitySnapshot } from '../../types/Activity';
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { ActivityWidget } from '../Activity';
|
||||
import { AgentsActivityWidget } from '../AgentsActivity';
|
||||
import { TodoProgressWidget } from '../TodoProgress';
|
||||
import { ToolsActivityWidget } from '../ToolsActivity';
|
||||
|
||||
const baseSnapshot: ActivitySnapshot = {
|
||||
tools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'Edit',
|
||||
target: '/repo/src/auth.ts',
|
||||
status: 'running',
|
||||
startTime: new Date(Date.now() - 20_000)
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'Read',
|
||||
status: 'completed',
|
||||
startTime: new Date(Date.now() - 50_000),
|
||||
endTime: new Date(Date.now() - 45_000)
|
||||
},
|
||||
{
|
||||
id: 'tool-3',
|
||||
name: 'Read',
|
||||
status: 'completed',
|
||||
startTime: new Date(Date.now() - 40_000),
|
||||
endTime: new Date(Date.now() - 35_000)
|
||||
}
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
type: 'explore',
|
||||
model: 'haiku',
|
||||
description: 'Investigating auth code path',
|
||||
status: 'running',
|
||||
startTime: new Date(Date.now() - 75_000)
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
type: 'reviewer',
|
||||
status: 'completed',
|
||||
startTime: new Date(Date.now() - 140_000),
|
||||
endTime: new Date(Date.now() - 95_000)
|
||||
}
|
||||
],
|
||||
todos: [
|
||||
{ content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ content: 'Add tests', status: 'completed' },
|
||||
{ content: 'Update docs', status: 'pending' }
|
||||
],
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
function renderWithContext(widget: { render: (item: WidgetItem, context: RenderContext, settings: typeof DEFAULT_SETTINGS) => string | null }, item: WidgetItem, context: RenderContext): string | null {
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('Activity widgets', () => {
|
||||
it('ToolsActivityWidget renders running and completed summary', () => {
|
||||
const widget = new ToolsActivityWidget();
|
||||
const output = renderWithContext(widget, { id: 'tools', type: 'tools-activity' }, { activity: baseSnapshot });
|
||||
|
||||
expect(output).toContain('Tools:');
|
||||
expect(output).toContain('◐ Edit');
|
||||
expect(output).toContain('✓ Read ×2');
|
||||
});
|
||||
|
||||
it('AgentsActivityWidget renders running and recently completed agents', () => {
|
||||
const widget = new AgentsActivityWidget();
|
||||
const output = renderWithContext(widget, { id: 'agents', type: 'agents-activity' }, { activity: baseSnapshot });
|
||||
|
||||
expect(output).toContain('Agents:');
|
||||
expect(output).toContain('◐ explore [haiku]');
|
||||
expect(output).toContain('✓ reviewer');
|
||||
});
|
||||
|
||||
it('TodoProgressWidget renders in-progress todo with completion ratio', () => {
|
||||
const widget = new TodoProgressWidget();
|
||||
const output = renderWithContext(widget, { id: 'todo', type: 'todo-progress' }, { activity: baseSnapshot });
|
||||
|
||||
expect(output).toContain('Todo:');
|
||||
expect(output).toContain('▸ Investigate auth bug');
|
||||
expect(output).toContain('(1/3)');
|
||||
});
|
||||
|
||||
it('TodoProgressWidget renders all complete state', () => {
|
||||
const widget = new TodoProgressWidget();
|
||||
const output = renderWithContext(
|
||||
widget,
|
||||
{ id: 'todo', type: 'todo-progress' },
|
||||
{
|
||||
activity: {
|
||||
...baseSnapshot,
|
||||
todos: [
|
||||
{ content: 'A', status: 'completed' },
|
||||
{ content: 'B', status: 'completed' }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(output).toBe('Todo: ✓ All complete (2/2)');
|
||||
});
|
||||
|
||||
it('ActivityWidget renders running-first compact summary and counters', () => {
|
||||
const widget = new ActivityWidget();
|
||||
const output = renderWithContext(widget, { id: 'activity', type: 'activity' }, { activity: baseSnapshot });
|
||||
|
||||
expect(output).toContain('All Activity:');
|
||||
expect(output).toContain('◐ Edit');
|
||||
expect(output).toContain('◐ explore');
|
||||
expect(output).toContain('▸ Investigate auth bug');
|
||||
expect(output).toContain('✓T2');
|
||||
expect(output).toContain('✓A1');
|
||||
expect(output).toContain('TD1/3');
|
||||
});
|
||||
|
||||
it('all activity widgets support width keybind and optional truncation', () => {
|
||||
const widgets = [
|
||||
new ToolsActivityWidget(),
|
||||
new AgentsActivityWidget(),
|
||||
new TodoProgressWidget(),
|
||||
new ActivityWidget()
|
||||
];
|
||||
|
||||
for (const widget of widgets) {
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 'w', label: '(w)idth', action: 'edit-width' }
|
||||
]);
|
||||
|
||||
const output = renderWithContext(
|
||||
widget,
|
||||
{ id: 'x', type: 'activity', maxWidth: 20 },
|
||||
{ activity: baseSnapshot }
|
||||
);
|
||||
|
||||
expect(output).not.toBeNull();
|
||||
expect(output?.endsWith('...')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('all activity widgets return preview output in preview mode', () => {
|
||||
const tools = renderWithContext(new ToolsActivityWidget(), { id: 'tools', type: 'tools-activity' }, { isPreview: true });
|
||||
const agents = renderWithContext(new AgentsActivityWidget(), { id: 'agents', type: 'agents-activity' }, { isPreview: true });
|
||||
const todo = renderWithContext(new TodoProgressWidget(), { id: 'todo', type: 'todo-progress' }, { isPreview: true });
|
||||
const activity = renderWithContext(new ActivityWidget(), { id: 'activity', type: 'activity' }, { isPreview: true });
|
||||
|
||||
expect(tools).toContain('Tools:');
|
||||
expect(agents).toContain('Agents:');
|
||||
expect(todo).toContain('Todo:');
|
||||
expect(activity).toContain('All Activity:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
ActivityAgentEntry,
|
||||
ActivityToolEntry
|
||||
} from '../types/Activity';
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
import {
|
||||
getVisibleWidth,
|
||||
truncateStyledText
|
||||
} from '../utils/ansi';
|
||||
|
||||
export function getMaxWidthModifier(item: WidgetItem): string | undefined {
|
||||
if (item.maxWidth && item.maxWidth > 0) {
|
||||
return `max:${item.maxWidth}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyMaxWidth(text: string, item: WidgetItem): string {
|
||||
if (!item.maxWidth || item.maxWidth <= 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (getVisibleWidth(text) <= item.maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return truncateStyledText(text, item.maxWidth, { ellipsis: true });
|
||||
}
|
||||
|
||||
export function truncateText(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
export function truncatePath(value: string, maxLength = 24): string {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/');
|
||||
const fileName = segments.pop() ?? normalized;
|
||||
|
||||
if (fileName.length >= maxLength) {
|
||||
return truncateText(fileName, maxLength);
|
||||
}
|
||||
|
||||
return `.../${fileName}`;
|
||||
}
|
||||
|
||||
export function formatElapsed(startTime: Date, endTime?: Date): string {
|
||||
const now = endTime ?? new Date();
|
||||
const elapsedMs = now.getTime() - startTime.getTime();
|
||||
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) {
|
||||
return '<1s';
|
||||
}
|
||||
if (elapsedMs < 1000) {
|
||||
return '<1s';
|
||||
}
|
||||
if (elapsedMs < 60000) {
|
||||
return `${Math.round(elapsedMs / 1000)}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(elapsedMs / 60000);
|
||||
const seconds = Math.round((elapsedMs % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function getToolCountSummary(tools: ActivityToolEntry[], limit = 3): [string, number][] {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const tool of tools) {
|
||||
const count = counts.get(tool.name) ?? 0;
|
||||
counts.set(tool.name, count + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => {
|
||||
if (a[1] !== b[1]) {
|
||||
return b[1] - a[1];
|
||||
}
|
||||
return a[0].localeCompare(b[0]);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function formatAgentLabel(agent: ActivityAgentEntry, maxDescriptionLength = 36): string {
|
||||
const model = agent.model ? ` [${agent.model}]` : '';
|
||||
const description = agent.description
|
||||
? `: ${truncateText(agent.description, maxDescriptionLength)}`
|
||||
: '';
|
||||
return `${agent.type}${model}${description}`;
|
||||
}
|
||||
@@ -63,3 +63,7 @@ export { GitWorktreeNameWidget } from './GitWorktreeName';
|
||||
export { GitWorktreeBranchWidget } from './GitWorktreeBranch';
|
||||
export { GitWorktreeOriginalBranchWidget } from './GitWorktreeOriginalBranch';
|
||||
export { CompactionCounterWidget } from './CompactionCounter';
|
||||
export { ToolsActivityWidget } from './ToolsActivity';
|
||||
export { AgentsActivityWidget } from './AgentsActivity';
|
||||
export { TodoProgressWidget } from './TodoProgress';
|
||||
export { ActivityWidget } from './Activity';
|
||||
|
||||
Reference in New Issue
Block a user