diff --git a/.github/instructions/copilot.instructions.md b/.github/instructions/copilot.instructions.md new file mode 100644 index 0000000000..f88dd76f0f --- /dev/null +++ b/.github/instructions/copilot.instructions.md @@ -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. diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 9532441f4e..df1ce851a2 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -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: [ diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index e0f68c613b..f0cb672d8f 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -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; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index ae771dade0..17e692133a 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -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"; diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 96b81b6b0f..35d79ab4c2 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -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); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 7d0d47e62b..149813ddca 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -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[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})` : ""}` diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 36cd0a02ce..6678bda5ae 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -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[] = []; diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts index bec83a8a23..2efe05408b 100644 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ b/src/auto-reply/reply/subagents-utils.test.ts @@ -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"); }); }); diff --git a/src/auto-reply/reply/subagents-utils.ts b/src/auto-reply/reply/subagents-utils.ts index 092ac6465d..1c5ecba118 100644 --- a/src/auto-reply/reply/subagents-utils.ts +++ b/src/auto-reply/reply/subagents-utils.ts @@ -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; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 0b3f842d01..1e92d89f81 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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(" • "); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index bd7f473c63..bd3dc0d415 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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}`; }; diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 8e0b8bd3d5..507b77ce99 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -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 ", "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(), diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 924778e57e..0c4c344a16 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -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 { 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"), }); } } diff --git a/src/cli/nodes-cli/format.ts b/src/cli/nodes-cli/format.ts index f4dc94fc88..646c5ac4cc 100644 --- a/src/cli/nodes-cli/format.ts +++ b/src/cli/nodes-cli/format.ts @@ -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) : {}; const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : []; diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 65b361cffc..218454e62a 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -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") : "", })); diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e8cdd52720..e7b7d682bf 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -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"), }; }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 62bd17f8be..7137653e34 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -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 { diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 145187c141..32915d6d82 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -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 0) { bits.push(`mode:${account.mode}`); diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index ea0c4fbb47..639de4f59d 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -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(""); }, diff --git a/src/commands/sandbox-formatters.test.ts b/src/commands/sandbox-formatters.test.ts index d8bf8383a8..5c3c985d0f 100644 --- a/src/commands/sandbox-formatters.test.ts +++ b/src/commands/sandbox-formatters.test.ts @@ -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 }); }); diff --git a/src/commands/sandbox-formatters.ts b/src/commands/sandbox-formatters.ts index 915017d191..f96fc631ff 100644 --- a/src/commands/sandbox-formatters.ts +++ b/src/commands/sandbox-formatters.ts @@ -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 */ diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 54235086cd..17a91f558a 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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"; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 8f04e985b8..25051a546b 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -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"; diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 0919211612..bb17e3c2e7 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -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}`); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 7a4447f7de..35da8ab97e 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -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"); diff --git a/src/commands/status-all/format.ts b/src/commands/status-all/format.ts index 979fa4e6db..da975564cf 100644 --- a/src/commands/status-all/format.ts +++ b/src/commands/status-all/format.ts @@ -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: { diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 84d2656dd2..71dc035ad8 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -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, })); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a8e288b82f..f05e86dc93 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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), })) diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts index 7572ca18cb..6050c9f6d0 100644 --- a/src/commands/status.format.ts +++ b/src/commands/status.format.ts @@ -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) => { diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index e6f85be9e3..3d26727471 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -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"; diff --git a/src/gateway/server-methods/agent-timestamp.test.ts b/src/gateway/server-methods/agent-timestamp.test.ts index 42afa0a820..a9ec330407 100644 --- a/src/gateway/server-methods/agent-timestamp.test.ts +++ b/src/gateway/server-methods/agent-timestamp.test.ts @@ -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" }); diff --git a/src/gateway/server-methods/agent-timestamp.ts b/src/gateway/server-methods/agent-timestamp.ts index 715262de28..53c5d6e3ab 100644 --- a/src/gateway/server-methods/agent-timestamp.ts +++ b/src/gateway/server-methods/agent-timestamp.ts @@ -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; } diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index d95a3adfe1..8fabcf87a3 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -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`; -} diff --git a/src/infra/format-datetime.ts b/src/infra/format-datetime.ts new file mode 100644 index 0000000000..490cc4c8eb --- /dev/null +++ b/src/infra/format-datetime.ts @@ -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}` : ""}`; +} diff --git a/src/infra/format-duration.ts b/src/infra/format-duration.ts index b6cb694d75..dc6be9d1fc 100644 --- a/src/infra/format-duration.ts +++ b/src/infra/format-duration.ts @@ -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`; +} diff --git a/src/infra/format-relative.ts b/src/infra/format-relative.ts new file mode 100644 index 0000000000..4b4da396fb --- /dev/null +++ b/src/infra/format-relative.ts @@ -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`; + } +} diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index d4f0891da7..a0c76e1209 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -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); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index bb1bae48b6..4eacb29eef 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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); diff --git a/src/tui/tui-status-summary.ts b/src/tui/tui-status-summary.ts index bda1b1b760..1215f381f6 100644 --- a/src/tui/tui-status-summary.ts +++ b/src/tui/tui-status-summary.ts @@ -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, diff --git a/src/utils/time-format.ts b/src/utils/time-format.ts index 188cec4c79..6c0720e688 100644 --- a/src/utils/time-format.ts +++ b/src/utils/time-format.ts @@ -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" }); } diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index 5ca1dd8649..7587fdc714 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -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 { diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 4260f07dac..956ebaf84a 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -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"); }); }); diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index 91debb2e41..8dfe06b59e 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -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 { if (!values || values.length === 0) { return "none"; diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 7c99380a86..13fa32722c 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -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})` : ""}`; } diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 1cc352d35e..e3c3a166e8 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -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`
${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`