mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cron): keep Unicode list columns aligned (#103889)
* refactor(cron): size table cells by display width * docs(changelog): credit cron table fix * fix(cron): sanitize Unicode table cells * test(cron): cover fitting ZWJ table cells * refactor(cron): format table cells consistently
This commit is contained in:
committed by
GitHub
parent
2a1d6e49d5
commit
92f3c35ee9
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
|
||||
- **Claude CLI warm sessions:** preserve managed stdio continuity when Claude writes no native transcript, fall back to bounded OpenClaw history only when the exact live child disappears or changes, and keep stateless runs from persisting CLI bindings. (#96841) Thanks @bradreaves.
|
||||
- **CLI plugin listing:** skip state-migration runtime loading when no legacy inputs exist, reducing packaged cold-start memory while preserving migrations for legacy plugin indexes and configured session stores.
|
||||
- **Unicode-safe bounded text:** preserve complete UTF-16 surrogate pairs when shortening previews, prompts, diagnostics, labels, session keys, link metadata, and identity values across Control UI, CLI, Gateway, plugins, QA, memory, and Android surfaces. (#102625, #102626, #102627, #102656, #102816, #102823, #102833, #102877, #102949, #102963, #102969, #102988, #103010, #103034, #103210, #103341, #103487, #103543, #103580, #103646) Thanks @zhangguiping-xydt, @wings1029, @wangyan2026, @Pandah97, @MoerAI, @SunnyShu0925, @zhangqueping, @zw-xysk, @cxbAsDev, @lzyyzznl, @coder-master-0915, @LeonidasLux, @mushuiyu886, @ly85206559, @Simon-XYDT, and @lsr911.
|
||||
- **Cron list table:** sanitize and size bounded cells by terminal display width so CJK, emoji, combining marks, and terminal-control input cannot corrupt alignment or output. (#103616) Thanks @mushuiyu886.
|
||||
- **CLI model tables:** sanitize, truncate, and pad model-list cells by rendered terminal width so emoji, CJK, and other wide graphemes keep columns aligned. (#102819) Thanks @Kevin23-design and @vincentkoc.
|
||||
- **Skills prompt compaction:** preserve every included skill identity before using the remaining prompt budget for shortened, UTF-16-safe descriptions, retaining trigger guidance without exceeding the hard limit. (#88426) Thanks @abel-zer0.
|
||||
- **Channel Markdown code tables:** size columns by rendered display width so CJK, emoji, and mixed-width cells stay aligned across shared Telegram and Discord output. (#55596, #55512) Thanks @sparkyrider.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Cron shared tests cover shared cron CLI parsing, display, and error helpers.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { visibleWidth } from "../../../packages/terminal-core/src/ansi.js";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import {
|
||||
@@ -90,19 +91,65 @@ describe("printCronList", () => {
|
||||
expectLogsToInclude(logs, "isolated");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["split surrogate", `${"x".repeat(20)}🚀tail`, `${"x".repeat(20)}...`],
|
||||
["ASCII boundary", `${"x".repeat(21)}Atail`, `${"x".repeat(21)}...`],
|
||||
["intact surrogate pair", `${"x".repeat(19)}🚀tail`, `${"x".repeat(19)}🚀...`],
|
||||
])("keeps %s truncation UTF-16 safe", (_label, name, expected) => {
|
||||
it("truncates and aligns names by sanitized terminal display width", () => {
|
||||
const { logs, runtime } = createRuntimeLogCapture();
|
||||
const prefix19 = "x".repeat(19);
|
||||
const prefix20 = "x".repeat(20);
|
||||
const prefix21 = "x".repeat(21);
|
||||
const injectedMarker = "cron-table-injection";
|
||||
const injectedControl = `\u001B]0;${injectedMarker}\u0007`;
|
||||
const cases = [
|
||||
{ name: `${prefix20}🚀tail`, expected: `${prefix20}...` },
|
||||
{ name: `${prefix21}Atail`, expected: `${prefix21}...` },
|
||||
{ name: `${prefix19}表tail`, expected: `${prefix19}表...` },
|
||||
{ name: `${prefix20}e\u0301tail`, expected: `${prefix20}e\u0301...` },
|
||||
{ name: `${prefix19}👨👩👧👦tail`, expected: `${prefix19}👨👩👧👦...` },
|
||||
{ name: `${prefix20}👨👩👧👦`, expected: `${prefix20}👨👩👧👦` },
|
||||
{ name: `${prefix20}${injectedControl}🚀tail`, expected: `${prefix20}...` },
|
||||
];
|
||||
|
||||
printCronList([createBaseJob({ name })], runtime);
|
||||
printCronList(
|
||||
cases.map(({ name }, index) => createBaseJob({ id: `unicode-name-${index}`, name })),
|
||||
runtime,
|
||||
);
|
||||
|
||||
expectLogsToInclude(logs, expected);
|
||||
const header = logs[0] ?? "";
|
||||
const rows = logs.slice(1);
|
||||
const scheduleColumn = visibleWidth(header.slice(0, header.indexOf("Schedule")));
|
||||
expect(rows).toHaveLength(cases.length);
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const scheduleIndex = row.indexOf("at ");
|
||||
expect(scheduleIndex).toBeGreaterThan(-1);
|
||||
expect(visibleWidth(row.slice(0, scheduleIndex))).toBe(scheduleColumn);
|
||||
expect(row).toContain(cases[index]?.expected);
|
||||
}
|
||||
const output = logs.join("\n");
|
||||
expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output);
|
||||
expect(output).not.toContain("\uFFFD");
|
||||
expect(output).not.toContain(injectedMarker);
|
||||
});
|
||||
|
||||
it("sanitizes and bounds named-session targets", () => {
|
||||
const { logs, runtime } = createRuntimeLogCapture();
|
||||
const injectedMarker = "cron-target-injection";
|
||||
const sessionTarget = `session:${"x".repeat(20)}\u001B]0;${injectedMarker}\u0007`;
|
||||
const job = createBaseJob({
|
||||
id: "target-job",
|
||||
sessionTarget: sessionTarget as CronJob["sessionTarget"],
|
||||
});
|
||||
|
||||
printCronList([job], runtime, {
|
||||
deliveryPreviews: new Map([[job.id, { label: "target-delivery", detail: "destination" }]]),
|
||||
});
|
||||
|
||||
const header = logs[0] ?? "";
|
||||
const row = logs[1] ?? "";
|
||||
const deliveryColumn = visibleWidth(header.slice(0, header.indexOf("Delivery")));
|
||||
const deliveryIndex = row.indexOf("target-delivery");
|
||||
expect(deliveryIndex).toBeGreaterThan(-1);
|
||||
expect(visibleWidth(row.slice(0, deliveryIndex))).toBe(deliveryColumn);
|
||||
expect(row).toContain("sessio...");
|
||||
expect(row).not.toContain(injectedMarker);
|
||||
});
|
||||
|
||||
it("shows declaration metadata and existing run status", () => {
|
||||
|
||||
+39
-49
@@ -7,7 +7,8 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { truncateToVisibleWidth, visibleWidth } from "../../../packages/terminal-core/src/ansi.js";
|
||||
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
|
||||
import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||
@@ -343,6 +344,7 @@ const CRON_DELIVERY_PAD = 64;
|
||||
const CRON_AGENT_PAD = 10;
|
||||
const CRON_OWNER_PAD = 24;
|
||||
const CRON_MODEL_PAD = 20;
|
||||
const TRUNCATED_SUFFIX = "...";
|
||||
|
||||
const stringifyCell = (value: unknown, fallback = "-") => {
|
||||
if (typeof value === "string") {
|
||||
@@ -354,16 +356,16 @@ const stringifyCell = (value: unknown, fallback = "-") => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const pad = (value: unknown, width: number) => stringifyCell(value).padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) {
|
||||
return value;
|
||||
}
|
||||
if (width <= 3) {
|
||||
return truncateUtf16Safe(value, width);
|
||||
}
|
||||
return `${truncateUtf16Safe(value, width - 3)}...`;
|
||||
const formatCell = (value: unknown, width: number) => {
|
||||
const text = sanitizeTerminalText(stringifyCell(value));
|
||||
const truncated =
|
||||
visibleWidth(text) <= width
|
||||
? text
|
||||
: width <= TRUNCATED_SUFFIX.length
|
||||
? truncateToVisibleWidth(text, width)
|
||||
: `${truncateToVisibleWidth(text, width - TRUNCATED_SUFFIX.length)}${TRUNCATED_SUFFIX}`;
|
||||
const remaining = width - visibleWidth(truncated);
|
||||
return remaining > 0 ? `${truncated}${" ".repeat(remaining)}` : truncated;
|
||||
};
|
||||
|
||||
const formatIsoMinute = (iso: string) => {
|
||||
@@ -457,18 +459,18 @@ export function printCronList(
|
||||
|
||||
const rich = isRich();
|
||||
const header = [
|
||||
pad("ID", CRON_ID_PAD),
|
||||
pad("Declaration", CRON_DECLARATION_PAD),
|
||||
pad("Name", CRON_NAME_PAD),
|
||||
pad("Schedule", CRON_SCHEDULE_PAD),
|
||||
pad("Next", CRON_NEXT_PAD),
|
||||
pad("Last", CRON_LAST_PAD),
|
||||
pad("Status", CRON_STATUS_PAD),
|
||||
pad("Target", CRON_TARGET_PAD),
|
||||
pad("Delivery", CRON_DELIVERY_PAD),
|
||||
pad("Agent ID", CRON_AGENT_PAD),
|
||||
pad("Owner", CRON_OWNER_PAD),
|
||||
pad("Model", CRON_MODEL_PAD),
|
||||
formatCell("ID", CRON_ID_PAD),
|
||||
formatCell("Declaration", CRON_DECLARATION_PAD),
|
||||
formatCell("Name", CRON_NAME_PAD),
|
||||
formatCell("Schedule", CRON_SCHEDULE_PAD),
|
||||
formatCell("Next", CRON_NEXT_PAD),
|
||||
formatCell("Last", CRON_LAST_PAD),
|
||||
formatCell("Status", CRON_STATUS_PAD),
|
||||
formatCell("Target", CRON_TARGET_PAD),
|
||||
formatCell("Delivery", CRON_DELIVERY_PAD),
|
||||
formatCell("Agent ID", CRON_AGENT_PAD),
|
||||
formatCell("Owner", CRON_OWNER_PAD),
|
||||
formatCell("Model", CRON_MODEL_PAD),
|
||||
].join(" ");
|
||||
|
||||
runtime.log(rich ? theme.heading(header) : header);
|
||||
@@ -476,42 +478,30 @@ export function printCronList(
|
||||
|
||||
for (const job of jobs) {
|
||||
const state = job.state ?? {};
|
||||
const idLabel = pad(job.id, CRON_ID_PAD);
|
||||
const declarationLabel = pad(
|
||||
truncate(job.declarationKey ?? "-", CRON_DECLARATION_PAD),
|
||||
CRON_DECLARATION_PAD,
|
||||
);
|
||||
const nameLabel = pad(
|
||||
truncate(stringifyCell(job.displayName ?? job.name), CRON_NAME_PAD),
|
||||
CRON_NAME_PAD,
|
||||
);
|
||||
const scheduleLabel = pad(
|
||||
truncate(formatSchedule(job.schedule, job.trigger !== undefined), CRON_SCHEDULE_PAD),
|
||||
const idLabel = formatCell(job.id, CRON_ID_PAD);
|
||||
const declarationLabel = formatCell(job.declarationKey, CRON_DECLARATION_PAD);
|
||||
const nameLabel = formatCell(job.displayName ?? job.name, CRON_NAME_PAD);
|
||||
const scheduleLabel = formatCell(
|
||||
formatSchedule(job.schedule, job.trigger !== undefined),
|
||||
CRON_SCHEDULE_PAD,
|
||||
);
|
||||
const nextLabel = pad(
|
||||
const nextLabel = formatCell(
|
||||
job.enabled ? formatRelative(state.nextRunAtMs, now) : "-",
|
||||
CRON_NEXT_PAD,
|
||||
);
|
||||
const lastLabel = pad(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD);
|
||||
const lastLabel = formatCell(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD);
|
||||
const statusRaw = computeStatus(job);
|
||||
const statusLabel = pad(formatCronStatusForDisplay(job), CRON_STATUS_PAD);
|
||||
const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
|
||||
const statusLabel = formatCell(formatCronStatusForDisplay(job), CRON_STATUS_PAD);
|
||||
const targetLabel = formatCell(job.sessionTarget, CRON_TARGET_PAD);
|
||||
const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
|
||||
const deliveryText = deliveryPreview
|
||||
? `${deliveryPreview.label} (${deliveryPreview.detail})`
|
||||
: "-";
|
||||
const deliveryLabel = pad(truncate(deliveryText, CRON_DELIVERY_PAD), CRON_DELIVERY_PAD);
|
||||
const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD);
|
||||
const ownerLabel = pad(
|
||||
truncate(job.owner?.sessionKey ?? job.owner?.agentId ?? "-", CRON_OWNER_PAD),
|
||||
CRON_OWNER_PAD,
|
||||
);
|
||||
const modelLabel = pad(
|
||||
truncate(
|
||||
(job.payload?.kind === "agentTurn" ? job.payload.model : undefined) ?? "-",
|
||||
CRON_MODEL_PAD,
|
||||
),
|
||||
const deliveryLabel = formatCell(deliveryText, CRON_DELIVERY_PAD);
|
||||
const agentLabel = formatCell(job.agentId, CRON_AGENT_PAD);
|
||||
const ownerLabel = formatCell(job.owner?.sessionKey ?? job.owner?.agentId, CRON_OWNER_PAD);
|
||||
const modelLabel = formatCell(
|
||||
job.payload?.kind === "agentTurn" ? job.payload.model : undefined,
|
||||
CRON_MODEL_PAD,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user