centralize date formatters

This commit is contained in:
quotentiroler
2026-02-08 03:45:17 -08:00
parent 74fbbda283
commit 27d135008c
60 changed files with 463 additions and 647 deletions

View File

@@ -0,0 +1,3 @@
Avoid a file that just re-exports from another file. Instead, import directly from the original file. This helps to reduce unnecessary indirection and makes it easier to understand where the code is coming from.
Avoid redundancies and inconsistencies. If a function already exists in one file, do not create a new function that does the same thing in another file. Instead, import the existing function and use it. This helps to keep the codebase clean and maintainable.

View File

@@ -10,9 +10,9 @@ import {
markExited,
setJobTtlMs,
} from "./bash-process-registry.js";
import { formatDurationCompact } from "../infra/format-duration.ts";
import {
deriveSessionName,
formatDuration,
killSession,
pad,
sliceLogLines,
@@ -118,7 +118,7 @@ export function createProcessTool(
.toSorted((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
});
return {
content: [

View File

@@ -244,19 +244,6 @@ function stripQuotes(value: string): string {
return trimmed;
}
export function formatDuration(ms: number) {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = Math.floor(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const rem = seconds % 60;
return `${minutes}m${rem.toString().padStart(2, "0")}s`;
}
export function pad(str: string, width: number) {
if (str.length >= width) {
return str;

View File

@@ -22,26 +22,10 @@ import {
queueEmbeddedPiMessage,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded.js";
import { formatDurationCompact } from "../infra/format-duration.ts";
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return undefined;
}
const totalSeconds = Math.round(valueMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m${seconds}s`;
}
return `${seconds}s`;
}
function formatTokenCount(value?: number) {
if (!value || !Number.isFinite(value)) {
return "0";
@@ -267,7 +251,7 @@ async function buildSubagentStatsLine(params: {
: undefined;
const parts: string[] = [];
const runtime = formatDurationShort(runtimeMs);
const runtime = formatDurationCompact(runtimeMs);
parts.push(`runtime ${runtime ?? "n/a"}`);
if (typeof total === "number") {
const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a";

View File

@@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveUserTimezone } from "../agents/date-time.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
import {
resolveTimezone,
formatUtcTimestamp,
formatZonedTimestamp,
} from "../infra/format-datetime.ts";
import { formatTimeAgo } from "../infra/format-relative.ts";
export type AgentEnvelopeParams = {
channel: string;
@@ -66,15 +72,6 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
};
}
function resolveExplicitTimezone(value: string): string | undefined {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
}
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) {
@@ -90,46 +87,10 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
if (lowered === "user") {
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
}
const explicit = resolveExplicitTimezone(trimmed);
const explicit = resolveTimezone(trimmed);
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
}
function formatUtcTimestamp(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
}
export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min) {
return undefined;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
}
function formatTimestamp(
ts: number | Date | undefined,
options?: EnvelopeFormatOptions,
@@ -152,47 +113,27 @@ function formatTimestamp(
if (zone.mode === "local") {
return formatZonedTimestamp(date);
}
return formatZonedTimestamp(date, zone.timeZone);
}
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
const elapsedMs = currentMs - previousMs;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return undefined;
}
const seconds = Math.floor(elapsedMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h`;
}
const days = Math.floor(hours / 24);
return `${days}d`;
return formatZonedTimestamp(date, { timeZone: zone.timeZone });
}
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const channel = params.channel?.trim() || "Channel";
const parts: string[] = [channel];
const resolved = normalizeEnvelopeOptions(params.envelope);
const elapsed =
resolved.includeElapsed && params.timestamp && params.previousTimestamp
? formatElapsedTime(
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp,
)
: undefined;
let elapsed: string | undefined;
if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) {
const currentMs =
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp;
const previousMs =
params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp;
const elapsedMs = currentMs - previousMs;
elapsed =
Number.isFinite(elapsedMs) && elapsedMs >= 0
? formatTimeAgo(elapsedMs, { suffix: false })
: undefined;
}
if (params.from?.trim()) {
const from = params.from.trim();
parts.push(elapsed ? `${from} +${elapsed}` : from);

View File

@@ -19,12 +19,12 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { stopSubagentsForRequester } from "./abort.js";
import { clearSessionQueues } from "./queue.js";
import {
formatAgeShort,
formatDurationShort,
formatRunLabel,
formatRunStatus,
sortSubagentRuns,
} from "./subagents-utils.js";
import { formatDurationCompact } from "../../infra/format-duration.ts";
import { formatTimeAgo } from "../../infra/format-relative.ts";
type SubagentTargetResolution = {
entry?: SubagentRunRecord;
@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
@@ -214,8 +214,8 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const label = formatRunLabel(entry);
const runtime =
entry.endedAt && entry.startedAt
? formatDurationShort(entry.endedAt - entry.startedAt)
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a")
: formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" });
const runId = entry.runId.slice(0, 8);
lines.push(
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
@@ -296,7 +296,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`

View File

@@ -5,6 +5,11 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { buildChannelSummary } from "../../infra/channel-summary.js";
import {
resolveTimezone,
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../infra/format-datetime.ts";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { drainSystemEventEntries } from "../../infra/system-events.js";
@@ -39,15 +44,6 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const resolveExplicitTimezone = (value: string): string | undefined => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) {
@@ -66,49 +62,10 @@ export async function prependSystemEvents(params: {
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
const explicit = resolveTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
const sec = String(date.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) {
return undefined;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
@@ -116,12 +73,14 @@ export async function prependSystemEvents(params: {
}
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") {
return formatUtcTimestamp(date);
return formatUtcTimestamp(date, { seconds: true });
}
if (zone.mode === "local") {
return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, { seconds: true }) ?? "unknown-time";
}
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
return (
formatZonedTimestamp(date, { timeZone: zone.timeZone, seconds: true }) ?? "unknown-time"
);
};
const systemLines: string[] = [];

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import { formatDurationCompact } from "../../infra/format-duration.js";
import {
formatDurationShort,
formatRunLabel,
formatRunStatus,
resolveSubagentLabel,
@@ -54,8 +54,8 @@ describe("subagents utils", () => {
);
});
it("formats duration short for seconds and minutes", () => {
expect(formatDurationShort(45_000)).toBe("45s");
expect(formatDurationShort(65_000)).toBe("1m5s");
it("formats duration compact for seconds and minutes", () => {
expect(formatDurationCompact(45_000)).toBe("45s");
expect(formatDurationCompact(65_000)).toBe("1m5s");
});
});

View File

@@ -1,42 +1,6 @@
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import { truncateUtf16Safe } from "../../utils.js";
export function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const totalSeconds = Math.round(valueMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m${seconds}s`;
}
return `${seconds}s`;
}
export function formatAgeShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const minutes = Math.round(valueMs / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
}
export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") {
const raw = entry.label?.trim() || entry.task?.trim() || "";
return raw || fallback;

View File

@@ -10,6 +10,7 @@ import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import {
resolveMainSessionKey,
resolveSessionFilePath,
@@ -134,24 +135,6 @@ export const formatContextUsageShort = (
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) {
@@ -386,7 +369,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
typeof updatedAt === "number" ? `updated ${formatAge(now - updatedAt)}` : "no activity",
typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity",
]
.filter(Boolean)
.join(" • ");

View File

@@ -2,6 +2,7 @@ import type { CronJob, CronSchedule } from "../../cron/types.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import { formatDurationHuman } from "../../infra/format-duration.ts";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { callGatewayFromCli } from "../gateway-rpc.js";
@@ -107,19 +108,6 @@ const formatIsoMinute = (iso: string) => {
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
};
const formatDuration = (ms: number) => {
if (ms < 60_000) {
return `${Math.max(1, Math.round(ms / 1000))}s`;
}
if (ms < 3_600_000) {
return `${Math.round(ms / 60_000)}m`;
}
if (ms < 86_400_000) {
return `${Math.round(ms / 3_600_000)}h`;
}
return `${Math.round(ms / 86_400_000)}d`;
};
const formatSpan = (ms: number) => {
if (ms < 60_000) {
return "<1m";
@@ -147,7 +135,7 @@ const formatSchedule = (schedule: CronSchedule) => {
return `at ${formatIsoMinute(schedule.at)}`;
}
if (schedule.kind === "every") {
return `every ${formatDuration(schedule.everyMs)}`;
return `every ${formatDurationHuman(schedule.everyMs)}`;
}
return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
};

View File

@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
@@ -49,23 +50,6 @@ type DevicePairingList = {
paired?: PairedDevice[];
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) {
return `${s}s`;
}
const m = Math.floor(s / 60);
if (m < 60) {
return `${m}m`;
}
const h = Math.floor(m / 60);
if (h < 24) {
return `${h}h`;
}
const d = Math.floor(h / 24);
return `${d}d`;
}
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
@@ -147,7 +131,7 @@ export function registerDevicesCli(program: Command) {
Device: req.displayName || req.deviceId,
Role: req.role ?? "",
IP: req.remoteIp ?? "",
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
Age: typeof req.ts === "number" ? formatTimeAgo(Date.now() - req.ts) : "",
Flags: req.isRepair ? "repair" : "",
})),
}).trimEnd(),

View File

@@ -8,6 +8,7 @@ import {
type ExecApprovalsAgent,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
@@ -31,22 +32,7 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
agent?: string;
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) {
return `${s}s`;
}
const m = Math.floor(s / 60);
if (m < 60) {
return `${m}m`;
}
const h = Math.floor(m / 60);
if (h < 24) {
return `${h}h`;
}
const d = Math.floor(h / 24);
return `${d}d`;
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
@@ -142,7 +128,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
Target: targetLabel,
Agent: agentId,
Pattern: pattern,
LastUsed: lastUsedAt ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` : muted("unknown"),
LastUsed: lastUsedAt ? formatTimeAgo(Math.max(0, now - lastUsedAt)) : muted("unknown"),
});
}
}

View File

@@ -1,22 +1,5 @@
import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./types.js";
export function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) {
return `${s}s`;
}
const m = Math.floor(s / 60);
if (m < 60) {
return `${m}m`;
}
const h = Math.floor(m / 60);
if (h < 24) {
return `${h}h`;
}
const d = Math.floor(h / 24);
return `${d}d`;
}
export function parsePairingList(value: unknown): PairingList {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : [];

View File

@@ -3,7 +3,8 @@ import type { NodesRpcOpts } from "./types.js";
import { defaultRuntime } from "../../runtime.js";
import { renderTable } from "../../terminal/table.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { formatAge, parsePairingList } from "./format.js";
import { parsePairingList } from "./format.js";
import { formatTimeAgo } from "../../infra/format-relative.ts";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
export function registerNodesPairingCommands(nodes: Command) {
@@ -33,7 +34,7 @@ export function registerNodesPairingCommands(nodes: Command) {
IP: r.remoteIp ?? "",
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
? formatTimeAgo(Math.max(0, now - r.ts))
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));

View File

@@ -5,7 +5,8 @@ import { renderTable } from "../../terminal/table.js";
import { shortenHomeInString } from "../../utils.js";
import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { formatTimeAgo } from "../../infra/format-relative.ts";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
function formatVersionLabel(raw: string) {
@@ -178,7 +179,7 @@ export function registerNodesStatusCommands(nodes: Command) {
const connected = n.connected ? ok("connected") : muted("disconnected");
const since =
typeof n.connectedAtMs === "number"
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})`
: "";
return {
@@ -361,7 +362,7 @@ export function registerNodesStatusCommands(nodes: Command) {
IP: r.remoteIp ?? "",
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
? formatTimeAgo(Math.max(0, now - r.ts))
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));
@@ -397,7 +398,7 @@ export function registerNodesStatusCommands(nodes: Command) {
IP: n.remoteIp ?? "",
LastConnect:
typeof lastConnectedAtMs === "number"
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
? formatTimeAgo(Math.max(0, now - lastConnectedAtMs))
: muted("unknown"),
};
});

View File

@@ -48,6 +48,7 @@ import {
type UpdateStepResult,
type UpdateStepProgress,
} from "../infra/update-runner.js";
import { formatDurationPrecise } from "../infra/format-duration.ts";
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
@@ -575,7 +576,7 @@ function createUpdateProgress(enabled: boolean): ProgressController {
}
const label = getStepLabel(step);
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
currentSpinner.stop(`${icon} ${label} ${duration}`);
@@ -603,14 +604,6 @@ function createUpdateProgress(enabled: boolean): ProgressController {
};
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = (ms / 1000).toFixed(1);
return `${seconds}s`;
}
function formatStepStatus(exitCode: number | null): string {
if (exitCode === 0) {
return theme.success("\u2713");
@@ -668,7 +661,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
defaultRuntime.log(theme.heading("Steps:"));
for (const step of result.steps) {
const status = formatStepStatus(step.exitCode);
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
if (step.exitCode !== 0 && step.stderrTail) {
@@ -683,7 +676,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
}
defaultRuntime.log("");
defaultRuntime.log(`Total time: ${theme.muted(formatDuration(result.durationMs))}`);
defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`);
}
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {

View File

@@ -5,7 +5,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
import { withProgress } from "../../cli/progress.js";
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatAge } from "../../infra/channel-summary.js";
import { formatTimeAgo } from "../../infra/format-relative.ts";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
@@ -48,10 +48,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
? account.lastOutboundAt
: null;
if (inboundAt) {
bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
bits.push(`in:${formatTimeAgo(Date.now() - inboundAt)}`);
}
if (outboundAt) {
bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
bits.push(`out:${formatTimeAgo(Date.now() - outboundAt)}`);
}
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);

View File

@@ -5,8 +5,8 @@
import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { formatDurationCompact } from "../infra/format-duration.ts";
import {
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
@@ -40,8 +40,8 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R
rt.log(` ${container.containerName}`);
rt.log(` Status: ${formatStatus(container.running)}`);
rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`);
rt.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`);
rt.log(` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`);
rt.log(` Age: ${(formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s")}`);
rt.log(` Idle: ${(formatDurationCompact(Date.now() - container.lastUsedAtMs, { spaced: true }) ?? "0s")}`);
rt.log(` Session: ${container.sessionKey}`);
rt.log("");
},
@@ -64,8 +64,8 @@ export function displayBrowsers(browsers: SandboxBrowserInfo[], runtime: Runtime
if (browser.noVncPort) {
rt.log(` noVNC: ${browser.noVncPort}`);
}
rt.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`);
rt.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`);
rt.log(` Age: ${(formatDurationCompact(Date.now() - browser.createdAtMs, { spaced: true }) ?? "0s")}`);
rt.log(` Idle: ${(formatDurationCompact(Date.now() - browser.lastUsedAtMs, { spaced: true }) ?? "0s")}`);
rt.log(` Session: ${browser.sessionKey}`);
rt.log("");
},

View File

@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatDurationCompact } from "../infra/format-duration.js";
import {
countMismatches,
countRunning,
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
} from "./sandbox-formatters.js";
/** Helper matching old formatAge behavior: spaced compound duration */
const formatAge = (ms: number) => formatDurationCompact(ms, { spaced: true }) ?? "0s";
describe("sandbox-formatters", () => {
describe("formatStatus", () => {
it("should format running status", () => {
@@ -47,21 +50,21 @@ describe("sandbox-formatters", () => {
it("should format minutes", () => {
expect(formatAge(60000)).toBe("1m");
expect(formatAge(90000)).toBe("1m");
expect(formatAge(90000)).toBe("1m 30s"); // 90 seconds = 1m 30s
expect(formatAge(300000)).toBe("5m");
});
it("should format hours and minutes", () => {
expect(formatAge(3600000)).toBe("1h 0m");
expect(formatAge(3600000)).toBe("1h");
expect(formatAge(3660000)).toBe("1h 1m");
expect(formatAge(7200000)).toBe("2h 0m");
expect(formatAge(7200000)).toBe("2h");
expect(formatAge(5400000)).toBe("1h 30m");
});
it("should format days and hours", () => {
expect(formatAge(86400000)).toBe("1d 0h");
expect(formatAge(86400000)).toBe("1d");
expect(formatAge(90000000)).toBe("1d 1h");
expect(formatAge(172800000)).toBe("2d 0h");
expect(formatAge(172800000)).toBe("2d");
expect(formatAge(183600000)).toBe("2d 3h");
});
@@ -70,9 +73,9 @@ describe("sandbox-formatters", () => {
});
it("should handle edge cases", () => {
expect(formatAge(59999)).toBe("59s"); // Just under 1 minute
expect(formatAge(3599999)).toBe("59m"); // Just under 1 hour
expect(formatAge(86399999)).toBe("23h 59m"); // Just under 1 day
expect(formatAge(59999)).toBe("1m"); // Rounds to 1 minute exactly
expect(formatAge(3599999)).toBe("1h"); // Rounds to 1 hour exactly
expect(formatAge(86399999)).toBe("1d"); // Rounds to 1 day exactly
});
});

View File

@@ -14,24 +14,6 @@ export function formatImageMatch(matches: boolean): string {
return matches ? "✓" : "⚠️ mismatch";
}
export function formatAge(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${seconds}s`;
}
/**
* Type guard and counter utilities
*/

View File

@@ -5,6 +5,7 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import { isRich, theme } from "../terminal/theme.js";
type SessionRow = {
@@ -90,7 +91,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
};
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown";
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(AGE_PAD);
return rich ? theme.muted(padded) : padded;
};
@@ -116,25 +117,6 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => {
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
};
const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
if (key === "global") {
return "global";

View File

@@ -28,7 +28,7 @@ import { VERSION } from "../version.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getAgentLocalStatuses } from "./status-all/agents.js";
import { buildChannelsTable } from "./status-all/channels.js";
import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js";
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
@@ -354,7 +354,7 @@ export async function statusAllCommand(
const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url;
const gatewayStatus = gatewayReachable
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
? `reachable ${formatDurationPrecise(gatewayProbe?.connectLatencyMs ?? 0)}`
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";

View File

@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
import { formatAge } from "./format.js";
import { formatTimeAgo } from "./format.js";
export type ChannelRow = {
id: ChannelId;
@@ -436,7 +436,7 @@ export async function buildChannelsTable(
extra.push(link.selfE164);
}
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
extra.push(`auth ${formatAge(link.authAgeMs)}`);
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
}
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
extra.push(`accounts ${accounts.length || 1}`);

View File

@@ -5,7 +5,7 @@ import {
type RestartSentinelPayload,
summarizeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { formatAge, redactSecrets } from "./format.js";
import { formatTimeAgo, redactSecrets } from "./format.js";
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
type ConfigIssueLike = { path: string; message: string };
@@ -106,7 +106,7 @@ export async function appendStatusAllDiagnosis(params: {
if (params.sentinel?.payload) {
emitCheck("Restart sentinel present", "warn");
lines.push(
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`,
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatTimeAgo(Date.now() - params.sentinel.payload.ts)}`)}`,
);
} else {
emitCheck("Restart sentinel: none", "ok");

View File

@@ -1,31 +1,5 @@
export const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) {
return "unknown";
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
};
export { formatTimeAgo } from "../../infra/format-relative.ts";
export { formatDurationPrecise } from "../../infra/format-duration.ts";
export function formatGatewayAuthUsed(
auth: {

View File

@@ -2,7 +2,7 @@ import type { ProgressReporter } from "../../cli/progress.js";
import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js";
import { appendStatusAllDiagnosis } from "./diagnosis.js";
import { formatAge } from "./format.js";
import { formatTimeAgo } from "./format.js";
type OverviewRow = { Item: string; Value: string };
@@ -128,7 +128,7 @@ export async function buildStatusAllReportLines(params: {
? ok("OK")
: "unknown",
Sessions: String(a.sessionsCount),
Active: a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown",
Store: a.sessionsPath,
}));

View File

@@ -26,12 +26,12 @@ import { statusAllCommand } from "./status-all.js";
import { formatGatewayAuthUsed } from "./status-all/format.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
formatAge,
formatDuration,
formatKTokens,
formatTokensCompact,
shortenText,
} from "./status.format.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import { scanStatus } from "./status.scan.js";
import {
@@ -239,7 +239,7 @@ export async function statusCommand(
? `${agentStatus.bootstrapPendingCount} bootstrapping`
: "no bootstraps";
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
const defActive = def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown";
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
@@ -294,7 +294,7 @@ export async function statusCommand(
if (!lastHeartbeat) {
return muted("none");
}
const age = formatAge(Date.now() - lastHeartbeat.ts);
const age = formatTimeAgo(Date.now() - lastHeartbeat.ts);
const channel = lastHeartbeat.channel ?? "unknown";
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
@@ -527,7 +527,7 @@ export async function statusCommand(
? summary.sessions.recent.map((sess) => ({
Key: shortenText(sess.key, 32),
Kind: sess.kind,
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
Age: sess.updatedAt ? formatTimeAgo(sess.age) : "no activity",
Model: sess.model ?? "unknown",
Tokens: formatTokensCompact(sess),
}))

View File

@@ -1,35 +1,14 @@
import type { SessionStatus } from "./status.types.js";
import { formatDurationPrecise } from "../infra/format-duration.ts";
export const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
export const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) {
return "unknown";
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
return formatDurationPrecise(ms, { decimals: 1 });
};
export const shortenText = (value: string, maxLen: number) => {

View File

@@ -7,7 +7,7 @@ import {
PresenceUpdateListener,
} from "@buape/carbon";
import { danger } from "../../globals.js";
import { formatDurationSeconds } from "../../infra/format-duration.js";
import { formatDurationSeconds } from "../../infra/format-duration.ts";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
import { formatZonedTimestamp } from "../../infra/format-datetime.js";
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
describe("injectTimestamp", () => {
@@ -23,7 +23,7 @@ describe("injectTimestamp", () => {
it("uses channel envelope format with DOW prefix", () => {
const now = new Date();
const expected = formatZonedTimestamp(now, "America/New_York");
const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" });
const result = injectTimestamp("hello", { timezone: "America/New_York" });

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../../config/types.js";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
import { formatZonedTimestamp } from "../../infra/format-datetime.ts";
/**
* Cron jobs inject "Current time: ..." into their messages.
@@ -56,7 +56,7 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
const now = opts?.now ?? new Date();
const timezone = opts?.timezone ?? "UTC";
const formatted = formatZonedTimestamp(now, timezone);
const formatted = formatZonedTimestamp(now, { timeZone: timezone });
if (!formatted) {
return message;
}

View File

@@ -1,6 +1,7 @@
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { formatTimeAgo } from "./format-relative.ts";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { theme } from "../terminal/theme.js";
@@ -224,7 +225,7 @@ export async function buildChannelSummary(
line += ` ${self.e164}`;
}
if (authAgeMs != null && authAgeMs >= 0) {
line += ` auth ${formatAge(authAgeMs)}`;
line += ` auth ${formatTimeAgo(authAgeMs)}`;
}
lines.push(tint(line, statusColor));
@@ -252,22 +253,3 @@ export async function buildChannelSummary(
return lines;
}
export function formatAge(ms: number): string {
if (ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
}

View File

@@ -0,0 +1,89 @@
/**
* Centralized date/time formatting utilities.
*
* All formatters are timezone-aware, using Intl.DateTimeFormat.
* Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone
* that previously lived in envelope.ts and session-updates.ts.
*/
/**
* Validate an IANA timezone string. Returns the string if valid, undefined otherwise.
*/
export function resolveTimezone(value: string): string | undefined {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
}
export type FormatTimestampOptions = {
/** Include seconds in the output. Default: false */
seconds?: boolean;
};
/**
* Format a Date as a UTC timestamp string.
*
* Without seconds: `2024-01-15T14:30Z`
* With seconds: `2024-01-15T14:30:05Z`
*/
export function formatUtcTimestamp(date: Date, options?: FormatTimestampOptions): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
if (!options?.seconds) {
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
}
const sec = String(date.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
}
/**
* Format a Date with timezone display using Intl.DateTimeFormat.
*
* Without seconds: `2024-01-15 14:30 EST`
* With seconds: `2024-01-15 14:30:05 EST`
*
* Returns undefined if Intl formatting fails.
*/
export function formatZonedTimestamp(
date: Date,
options?: { timeZone?: string; seconds?: boolean },
): string | undefined {
const intlOptions: Intl.DateTimeFormatOptions = {
timeZone: options?.timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
};
if (options?.seconds) {
intlOptions.second = "2-digit";
}
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = options?.seconds ? pick("second") : undefined;
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min) {
return undefined;
}
if (options?.seconds && sec) {
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
}

View File

@@ -18,12 +18,8 @@ export function formatDurationSeconds(
return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`;
}
export type FormatDurationMsOptions = {
decimals?: number;
unit?: "s" | "seconds";
};
export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string {
/** Precise decimal-seconds output: "500ms" or "1.23s". Input is milliseconds. */
export function formatDurationPrecise(ms: number, options: FormatDurationSecondsOptions = {}): string {
if (!Number.isFinite(ms)) {
return "unknown";
}
@@ -35,3 +31,70 @@ export function formatDurationMs(ms: number, options: FormatDurationMsOptions =
unit: options.unit ?? "s",
});
}
/**
* Compact compound duration: "500ms", "45s", "2m5s", "1h30m".
* With `spaced`: "45s", "2m 5s", "1h 30m".
* Omits trailing zero components: "1m" not "1m 0s", "2h" not "2h 0m".
* Returns undefined for null/undefined/non-finite/non-positive input.
*/
export function formatDurationCompact(ms: number, options?: { spaced?: boolean }): string;
export function formatDurationCompact(
ms?: number | null,
options?: { spaced?: boolean },
): string | undefined;
export function formatDurationCompact(
ms?: number | null,
options?: { spaced?: boolean },
): string | undefined {
if (ms == null || !Number.isFinite(ms) || ms <= 0) {
return undefined;
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const sep = options?.spaced ? " " : "";
const totalSeconds = Math.round(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d${sep}${remainingHours}h` : `${days}d`;
}
if (hours > 0) {
return minutes > 0 ? `${hours}h${sep}${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m${sep}${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
/**
* Rounded single-unit duration for display: "500ms", "5s", "3m", "2h", "5d".
* Returns fallback string for null/undefined/non-finite input.
*/
export function formatDurationHuman(ms?: number | null, fallback = "n/a"): string {
if (ms == null || !Number.isFinite(ms) || ms < 0) {
return fallback;
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 24) {
return `${hr}h`;
}
const day = Math.round(hr / 24);
return `${day}d`;
}

View File

@@ -0,0 +1,112 @@
/**
* Centralized relative-time formatting utilities.
*
* Consolidates 7+ scattered implementations (formatAge, formatAgeShort, formatAgo,
* formatRelativeTime, formatElapsedTime) into two functions:
*
* - `formatTimeAgo(durationMs)` — format a duration as "5m ago" / "5m" (for known elapsed time)
* - `formatRelativeTimestamp(epochMs)` — format an epoch timestamp relative to now (handles future)
*/
export type FormatTimeAgoOptions = {
/** Append "ago" suffix. Default: true. When false, returns bare unit: "5m", "2h" */
suffix?: boolean;
/** Return value for invalid/null/negative input. Default: "unknown" */
fallback?: string;
};
/**
* Format a duration (in ms) as a human-readable relative time.
*
* Input: how many milliseconds ago something happened.
*
* With suffix (default): "just now", "5m ago", "3h ago", "2d ago"
* Without suffix: "0s", "5m", "3h", "2d"
*/
export function formatTimeAgo(
durationMs: number | null | undefined,
options?: FormatTimeAgoOptions,
): string {
const suffix = options?.suffix !== false;
const fallback = options?.fallback ?? "unknown";
if (durationMs == null || !Number.isFinite(durationMs) || durationMs < 0) {
return fallback;
}
const totalSeconds = Math.round(durationMs / 1000);
const minutes = Math.round(totalSeconds / 60);
if (minutes < 1) {
return suffix ? "just now" : `${totalSeconds}s`;
}
if (minutes < 60) {
return suffix ? `${minutes}m ago` : `${minutes}m`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return suffix ? `${hours}h ago` : `${hours}h`;
}
const days = Math.round(hours / 24);
return suffix ? `${days}d ago` : `${days}d`;
}
export type FormatRelativeTimestampOptions = {
/** If true, fall back to short date (e.g. "Oct 5") for timestamps >7 days. Default: false */
dateFallback?: boolean;
/** IANA timezone for date fallback display */
timezone?: string;
/** Return value for invalid/null input. Default: "n/a" */
fallback?: string;
};
/**
* Format an epoch timestamp relative to now.
*
* Handles both past ("5m ago") and future ("in 5m") timestamps.
* Optionally falls back to a short date for timestamps older than 7 days.
*/
export function formatRelativeTimestamp(
timestampMs: number | null | undefined,
options?: FormatRelativeTimestampOptions,
): string {
const fallback = options?.fallback ?? "n/a";
if (timestampMs == null || !Number.isFinite(timestampMs)) {
return fallback;
}
const diff = Date.now() - timestampMs;
const absDiff = Math.abs(diff);
const isPast = diff >= 0;
const sec = Math.round(absDiff / 1000);
if (sec < 60) {
return isPast ? "just now" : "in <1m";
}
const min = Math.round(sec / 60);
if (min < 60) {
return isPast ? `${min}m ago` : `in ${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return isPast ? `${hr}h ago` : `in ${hr}h`;
}
const day = Math.round(hr / 24);
if (!options?.dateFallback || day <= 7) {
return isPast ? `${day}d ago` : `in ${day}d`;
}
// Fall back to short date display for old timestamps
try {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
...(options.timezone ? { timeZone: options.timezone } : {}),
}).format(new Date(timestampMs));
} catch {
return `${day}d ago`;
}
}

View File

@@ -5,7 +5,7 @@ import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
import { loadConfig } from "../config/config.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { formatErrorMessage } from "../infra/errors.js";
import { formatDurationMs } from "../infra/format-duration.js";
import { formatDurationPrecise } from "../infra/format-duration.ts";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
@@ -195,7 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const reason = isConflict ? "getUpdates conflict" : "network error";
const errMsg = formatErrorMessage(err);
(opts.runtime?.error ?? console.error)(
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`,
);
try {
await sleepWithAbort(delayMs, opts.abortSignal);

View File

@@ -15,7 +15,7 @@ import {
resolveResponseUsageMode,
} from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { formatRelativeTime } from "../utils/time-format.js";
import { formatRelativeTimestamp } from "../infra/format-relative.ts";
import { helpText, parseCommand } from "./commands.js";
import {
createFilterableSelectList,
@@ -158,7 +158,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
// Avoid redundant "title (key)" when title matches key
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
// Build description: time + message preview
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
const timePart = session.updatedAt ? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" }) : "";
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
const description =
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);

View File

@@ -1,5 +1,5 @@
import type { GatewayStatusSummary } from "./tui-types.js";
import { formatAge } from "../infra/channel-summary.js";
import { formatTimeAgo } from "../infra/format-relative.ts";
import { formatTokenCount } from "../utils/usage-format.js";
import { formatContextUsageLine } from "./tui-formatters.js";
@@ -14,7 +14,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
const linked = summary.linkChannel.linked === true;
const authAge =
linked && typeof summary.linkChannel.authAgeMs === "number"
? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})`
? ` (last refreshed ${formatTimeAgo(summary.linkChannel.authAgeMs)})`
: "";
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
}
@@ -63,7 +63,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
if (recent.length > 0) {
lines.push("Recent sessions:");
for (const entry of recent) {
const ageLabel = typeof entry.age === "number" ? formatAge(entry.age) : "no activity";
const ageLabel = typeof entry.age === "number" ? formatTimeAgo(entry.age) : "no activity";
const model = entry.model ?? "unknown";
const usage = formatContextUsageLine({
total: entry.totalTokens ?? null,

View File

@@ -1,25 +1,6 @@
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
import { formatRelativeTimestamp } from "../infra/format-relative.ts";
if (seconds < 60) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
if (hours < 24) {
return `${hours}h ago`;
}
if (days === 1) {
return "Yesterday";
}
if (days < 7) {
return `${days}d ago`;
}
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
/** Delegates to centralized formatRelativeTimestamp with date fallback for >7d. */
export function formatRelativeTime(timestamp: number): string {
return formatRelativeTimestamp(timestamp, { dateFallback: true, fallback: "unknown" });
}

View File

@@ -7,7 +7,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
import { waitForever } from "../../cli/wait.js";
import { loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { formatDurationMs } from "../../infra/format-duration.js";
import { formatDurationPrecise } from "../../infra/format-duration.ts";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import { getChildLogger } from "../../logging.js";
@@ -432,7 +432,7 @@ export async function monitorWebChannel(
"web reconnect: scheduling retry",
);
runtime.error(
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationMs(delay)}… (${errorStr})`,
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(delay)}… (${errorStr})`,
);
await closeListener();
try {

View File

@@ -1,34 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatAgo, stripThinkingTags } from "./format.ts";
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
describe("formatAgo", () => {
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
expect(formatAgo(Date.now() + 30_000)).toBe("in <1m");
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
});
it("returns 'Xm from now' for future timestamps", () => {
expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now");
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("5m from now");
});
it("returns 'Xh from now' for future timestamps", () => {
expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
});
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
});
it("returns 'Xs ago' for recent past timestamps", () => {
expect(formatAgo(Date.now() - 10_000)).toBe("10s ago");
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("10s ago");
});
it("returns 'Xm ago' for past timestamps", () => {
expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
});
it("returns 'n/a' for null/undefined", () => {
expect(formatAgo(null)).toBe("n/a");
expect(formatAgo(undefined)).toBe("n/a");
expect(formatRelativeTimestamp(null)).toBe("n/a");
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
});
});

View File

@@ -1,5 +1,9 @@
import { formatDurationHuman } from "../../../src/infra/format-duration.ts";
import { formatRelativeTimestamp } from "../../../src/infra/format-relative.ts";
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export { formatRelativeTimestamp, formatDurationHuman };
export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
@@ -7,52 +11,6 @@ export function formatMs(ms?: number | null): string {
return new Date(ms).toLocaleString();
}
export function formatAgo(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
}
const diff = Date.now() - ms;
const absDiff = Math.abs(diff);
const suffix = diff < 0 ? "from now" : "ago";
const sec = Math.round(absDiff / 1000);
if (sec < 60) {
return diff < 0 ? "in <1m" : `${sec}s ago`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m ${suffix}`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return `${hr}h ${suffix}`;
}
const day = Math.round(hr / 24);
return `${day}d ${suffix}`;
}
export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
}
if (ms < 1000) {
return `${ms}ms`;
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return `${hr}h`;
}
const day = Math.round(hr / 24);
return `${day}d`;
}
export function formatList(values?: Array<string | null | undefined>): string {
if (!values || values.length === 0) {
return "none";

View File

@@ -1,5 +1,5 @@
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
import { formatAgo, formatDurationMs, formatMs } from "./format.ts";
import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts";
export function formatPresenceSummary(entry: PresenceEntry): string {
const host = entry.host ?? "unknown";
@@ -11,14 +11,14 @@ export function formatPresenceSummary(entry: PresenceEntry): string {
export function formatPresenceAge(entry: PresenceEntry): string {
const ts = entry.ts ?? null;
return ts ? formatAgo(ts) : "n/a";
return ts ? formatRelativeTimestamp(ts) : "n/a";
}
export function formatNextRun(ms?: number | null) {
if (!ms) {
return "n/a";
}
return `${formatMs(ms)} (${formatAgo(ms)})`;
return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
}
export function formatSessionTokens(row: GatewaySessionRow) {
@@ -57,7 +57,7 @@ export function formatCronSchedule(job: CronJob) {
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
}
if (s.kind === "every") {
return `Every ${formatDurationMs(s.everyMs)}`;
return `Every ${formatDurationHuman(s.everyMs)}`;
}
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
}

View File

@@ -16,7 +16,7 @@ import {
normalizeToolName,
resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy.js";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import {
formatCronPayload,
formatCronSchedule,
@@ -1112,7 +1112,7 @@ function renderAgentChannels(params: {
params.agentIdentity,
);
const entries = resolveChannelEntries(params.snapshot);
const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never";
const lastSuccessLabel = params.lastSuccess ? formatRelativeTimestamp(params.lastSuccess) : "never";
return html`
<section class="grid grid-cols-2">
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")}
@@ -1407,7 +1407,7 @@ function renderAgentFiles(params: {
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
const status = file.missing
? "Missing"
: `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`;
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
return html`
<button
type="button"

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { DiscordStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderDiscordCard(params: {
@@ -28,11 +28,11 @@ export function renderDiscordCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
<span>${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
<span>${discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { GoogleChatStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderGoogleChatCard(params: {
@@ -42,11 +42,11 @@ export function renderGoogleChatCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
<span>${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<span>${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { IMessageStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderIMessageCard(params: {
@@ -28,11 +28,11 @@ export function renderIMessageCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
<span>${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
<span>${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import {
renderNostrProfileForm,
@@ -79,7 +79,7 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError
@@ -213,7 +213,7 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`

View File

@@ -2,22 +2,6 @@ import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types.ts";
import type { ChannelKey, ChannelsProps } from "./channels.types.ts";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) {
return "n/a";
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { SignalStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderSignalCard(params: {
@@ -32,11 +32,11 @@ export function renderSignalCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
<span>${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<span>${signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { SlackStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderSlackCard(params: {
@@ -28,11 +28,11 @@ export function renderSlackCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
<span>${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
<span>${slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderTelegramCard(params: {
@@ -36,7 +36,7 @@ export function renderTelegramCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError
@@ -81,11 +81,11 @@ export function renderTelegramCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
<span>${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
<span>${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`

View File

@@ -14,7 +14,7 @@ import type {
WhatsAppStatus,
} from "../types.ts";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import { renderDiscordCard } from "./channels.discord.ts";
import { renderGoogleChatCard } from "./channels.googlechat.ts";
@@ -73,7 +73,7 @@ export function renderChannels(props: ChannelsProps) {
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
<div class="muted">${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}</div>
</div>
${
props.lastError
@@ -308,7 +308,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError

View File

@@ -1,9 +1,8 @@
import { html, nothing } from "lit";
import type { WhatsAppStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import { formatDuration } from "./channels.shared.ts";
export function renderWhatsAppCard(params: {
props: ChannelsProps;
@@ -38,19 +37,19 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last message</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
import type { CronFormState } from "../ui-types.ts";
import { formatAgo, formatMs } from "../format.ts";
import { formatRelativeTimestamp, formatMs } from "../format.ts";
import { pathForTab } from "../navigation.ts";
import { formatCronSchedule, formatNextRun } from "../presenter.ts";
@@ -482,7 +482,7 @@ function formatStateRelative(ms?: number) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "n/a";
}
return formatAgo(ms);
return formatRelativeTimestamp(ms);
}
function renderJobState(job: CronJob) {

View File

@@ -10,7 +10,7 @@ import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals.ts";
import { clampText, formatAgo, formatList } from "../format.ts";
import { clampText, formatRelativeTimestamp, formatList } from "../format.ts";
export type NodesProps = {
loading: boolean;
@@ -130,7 +130,7 @@ function renderDevices(props: NodesProps) {
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
@@ -189,7 +189,7 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
const when = formatRelativeTimestamp(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
@@ -931,7 +931,7 @@ function renderAllowlistEntry(
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`

View File

@@ -1,7 +1,7 @@
import { html } from "lit";
import type { GatewayHelloOk } from "../gateway.ts";
import type { UiSettings } from "../storage.ts";
import { formatAgo, formatDurationMs } from "../format.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import { formatNextRun } from "../presenter.ts";
export type OverviewProps = {
@@ -26,7 +26,7 @@ export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authHint = (() => {
if (props.connected || !props.lastError) {
@@ -198,7 +198,7 @@ export function renderOverview(props: OverviewProps) {
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-value">
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : "n/a"}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { html, nothing } from "lit";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
@@ -221,7 +221,7 @@ function renderRow(
onDelete: SessionsProps["onDelete"],
disabled: boolean,
) {
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
const rawThinking = row.thinkingLevel ?? "";
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);

View File

@@ -1,4 +1,5 @@
import { html, svg, nothing } from "lit";
import { formatDurationCompact } from "../../../../src/infra/format-duration.ts";
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
// Inline styles for usage view (app uses light DOM, so static styles don't work)
@@ -2461,19 +2462,6 @@ function formatIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function formatDurationShort(ms?: number): string {
if (!ms || ms <= 0) {
return "0s";
}
if (ms >= 60_000) {
return `${Math.round(ms / 60000)}m`;
}
if (ms >= 1000) {
return `${Math.round(ms / 1000)}s`;
}
return `${Math.round(ms)}ms`;
}
function parseYmdDate(dateStr: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
if (!match) {
@@ -2500,23 +2488,6 @@ function formatFullDate(dateStr: string): string {
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
}
function formatDurationMs(ms?: number): string {
if (!ms || ms <= 0) {
return "—";
}
const totalSeconds = Math.round(ms / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function downloadTextFile(filename: string, content: string, type = "text/plain") {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
@@ -3467,7 +3438,7 @@ function renderUsageInsights(
stats.throughputCostPerMin !== undefined
? `${formatCost(stats.throughputCostPerMin, 4)} / min`
: "—";
const avgDurationLabel = stats.durationCount > 0 ? formatDurationShort(stats.avgDurationMs) : "—";
const avgDurationLabel = stats.durationCount > 0 ? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—") : "—";
const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
const errorHint = "Error rate = errors / total messages. Lower is better.";
const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
@@ -3672,7 +3643,7 @@ function renderSessionsCard(
parts.push(`errors:${s.usage.messageCounts.errors}`);
}
if (showColumn("duration") && s.usage?.durationMs) {
parts.push(`dur:${formatDurationMs(s.usage.durationMs)}`);
parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
}
return parts;
};
@@ -3976,7 +3947,7 @@ function renderSessionSummary(session: UsageSessionEntry) {
</div>
<div class="session-summary-card">
<div class="session-summary-title">Duration</div>
<div class="session-summary-value">${formatDurationMs(usage.durationMs)}</div>
<div class="session-summary-value">${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}</div>
<div class="session-summary-meta">${formatTs(usage.firstActivity)}${formatTs(usage.lastActivity)}</div>
</div>
</div>