Compare commits

...

1 Commits

Author SHA1 Message Date
Matthew Breedlove 458a45ac91 feat: add activity widgets and transcript activity parsing
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-28 00:40:54 -04:00
19 changed files with 1381 additions and 1 deletions
+6
View File
@@ -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.
+8
View File
@@ -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
+17
View File
@@ -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
};
+41
View File
@@ -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;
}
+2
View File
@@ -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;
+10
View File
@@ -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';
+173
View File
@@ -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);
});
});
+7
View File
@@ -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[] = [
{
+384
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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
}));
+157
View File
@@ -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; }
}
+54
View File
@@ -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>
);
};
+79
View File
@@ -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; }
}
+78
View File
@@ -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; }
}
+81
View File
@@ -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:');
});
});
+97
View File
@@ -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}`;
}
+4
View File
@@ -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';