Files
grok-build-hud/src/activity.ts
T
Redredchen01 bb6a1321fb Release grok-build-hud v0.3.0: Claude-HUD-style same-window status for Grok Build
Multi-line tmux strip (context, quota, tools, todos, git), theme sync with
Grok UI, full/essential/minimal presets, one-shot installer, EN+ZH docs,
and 40 unit tests.
2026-07-17 15:45:25 +08:00

286 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import type {
AgentActivityItem,
TodoItem,
ToolActivityItem,
} from "./types.js";
interface ToolState {
id: string;
name: string;
status: ToolActivityItem["status"];
detail?: string;
lastTs: number;
}
/**
* Parse Grok `updates.jsonl` for tool / agent activity.
* Pure-ish: accepts optional pre-split lines for tests.
*/
export function parseUpdatesLines(lines: string[]): {
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
} {
const tools = new Map<string, ToolState>();
const agents: AgentActivityItem[] = [];
let todos: TodoItem[] = [];
let lineNo = 0;
for (const raw of lines) {
lineNo += 1;
const line = raw.trim();
if (!line) continue;
let obj: Record<string, unknown>;
try {
obj = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const params = (obj.params as Record<string, unknown> | undefined) ?? {};
const update =
(params.update as Record<string, unknown> | undefined) ??
(obj.update as Record<string, unknown> | undefined) ??
{};
const kind =
(update.sessionUpdate as string | undefined) ??
(obj.sessionUpdate as string | undefined) ??
"";
const ts =
typeof obj.timestamp === "number"
? obj.timestamp
: typeof obj.ts === "string"
? Date.parse(obj.ts) / 1000
: lineNo;
if (kind === "tool_call" || kind === "tool_call_update") {
const id =
(update.toolCallId as string | undefined) ??
(update.id as string | undefined) ??
`tool-${lineNo}`;
const title = (update.title as string | undefined) ?? "";
const statusRaw = (update.status as string | undefined) ?? "";
const prev = tools.get(id);
let name = prev?.name ?? "tool";
if (title && !title.includes(" ") && title.length < 64) {
name = title;
} else if (title && prev?.name === "tool") {
// Prefer short tool name from earlier tool_call event
name = prev.name;
}
// On initial tool_call, title is the tool name
if (kind === "tool_call" && title) {
name = title.split(/\s+/)[0] || title;
}
let status: ToolActivityItem["status"] = prev?.status ?? "running";
if (statusRaw === "completed" || statusRaw === "complete" || statusRaw === "success") {
status = "completed";
} else if (statusRaw === "failed" || statusRaw === "error") {
status = "failed";
} else if (kind === "tool_call") {
status = "running";
}
let detail = prev?.detail;
if (kind === "tool_call_update" && title && title.length < 120) {
detail = title;
}
const rawInput = update.rawInput;
if (!detail && rawInput != null) {
detail = summarizeRawInput(rawInput);
}
tools.set(id, {
id,
name,
status,
detail,
lastTs: ts,
});
// Capture latest todo list from todo_write
if (
name === "todo_write" ||
name === "TodoWrite" ||
(typeof title === "string" && /todo/i.test(title))
) {
const extracted = extractTodos(rawInput ?? update);
if (extracted.length) todos = extracted;
}
continue;
}
if (
kind === "agent_message_chunk" ||
kind === "agent_thought_chunk" ||
kind.includes("agent")
) {
const title =
(update.title as string | undefined) ??
(update.agentType as string | undefined) ??
(update.name as string | undefined);
if (title || kind === "agent_message_chunk") {
agents.push({
id: (update.agentId as string | undefined) ?? (update.toolCallId as string | undefined),
title: title ?? kind,
status: (update.status as string | undefined) ?? "active",
detail:
typeof update.content === "string"
? update.content.slice(0, 80)
: undefined,
});
}
}
}
// Aggregate completed tools by name for a compact summary
const sorted = [...tools.values()].sort((a, b) => b.lastTs - a.lastTs);
const recent = sorted.slice(0, 12);
const completedCounts = new Map<string, number>();
const items: ToolActivityItem[] = [];
for (const t of recent) {
if (t.status === "running") {
items.push({
id: t.id,
name: t.name,
status: "running",
detail: t.detail,
});
} else {
completedCounts.set(t.name, (completedCounts.get(t.name) ?? 0) + 1);
}
}
for (const [name, count] of completedCounts) {
items.push({
id: `agg-${name}`,
name,
status: "completed",
count,
});
}
// Keep running first, then completed aggregates
items.sort((a, b) => {
if (a.status === "running" && b.status !== "running") return -1;
if (b.status === "running" && a.status !== "running") return 1;
return 0;
});
return {
tools: items.slice(0, 8),
agents: agents.slice(-3),
todos,
};
}
export function parseUpdatesFile(
filePath: string,
options: { maxTailBytes?: number } = {},
): {
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
} {
if (!fs.existsSync(filePath)) {
return { tools: [], agents: [], todos: [] };
}
const maxTail = options.maxTailBytes ?? 256_000;
try {
const stat = fs.statSync(filePath);
let content: string;
if (stat.size <= maxTail) {
content = fs.readFileSync(filePath, "utf8");
} else {
const fd = fs.openSync(filePath, "r");
try {
const buf = Buffer.alloc(maxTail);
fs.readSync(fd, buf, 0, maxTail, stat.size - maxTail);
content = buf.toString("utf8");
// Drop partial first line
const nl = content.indexOf("\n");
if (nl >= 0) content = content.slice(nl + 1);
} finally {
fs.closeSync(fd);
}
}
return parseUpdatesLines(content.split(/\r?\n/));
} catch {
return { tools: [], agents: [], todos: [] };
}
}
function extractTodos(raw: unknown): TodoItem[] {
if (raw == null) return [];
let obj: unknown = raw;
if (typeof raw === "string") {
try {
obj = JSON.parse(raw.replace(/'/g, '"'));
} catch {
return [];
}
}
if (!obj || typeof obj !== "object") return [];
const o = obj as Record<string, unknown>;
const list = (o.todos ?? o.TodoWrite ?? o.items) as unknown;
if (!Array.isArray(list)) return [];
const out: TodoItem[] = [];
for (const item of list) {
if (!item || typeof item !== "object") continue;
const t = item as Record<string, unknown>;
const content = String(t.content ?? t.title ?? t.text ?? "").trim();
if (!content) continue;
out.push({
content,
status: String(t.status ?? "pending"),
id: t.id != null ? String(t.id) : undefined,
});
}
return out;
}
function summarizeRawInput(raw: unknown): string | undefined {
if (raw == null) return undefined;
if (typeof raw === "string") {
try {
return summarizeRawInput(JSON.parse(raw.replace(/'/g, '"')));
} catch {
return raw.slice(0, 60);
}
}
if (typeof raw === "object") {
const o = raw as Record<string, unknown>;
const command = o.command ?? o.target_file ?? o.path ?? o.query ?? o.pattern;
if (typeof command === "string") return command.slice(0, 60);
}
return undefined;
}
/** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */
export function formatToolLine(tools: ToolActivityItem[]): string {
if (!tools.length) return "";
const parts: string[] = [];
for (const t of tools) {
if (t.status === "running") {
const d = t.detail ? `: ${truncate(t.detail, 40)}` : "";
parts.push(`◐ ${t.name}${d}`);
} else if (t.status === "failed") {
parts.push(`✗ ${t.name}`);
} else {
const n = t.count && t.count > 1 ? ` ×${t.count}` : "";
parts.push(`✓ ${t.name}${n}`);
}
}
return parts.join(" | ");
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}