commit 73e63363434a5c5aac3cc189304e2a0c3db3db28
parent 4280aa2434c66d5f658b6f5e0d166b3dd7bb030b
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 17:07:57 +0200
util: wrap help output to the terminal width in clk
Descriptions long enough to need wrapping were left to the terminal, which
folds them into the description column and makes the listing hard to scan.
Diffstat:
6 files changed, 109 insertions(+), 9 deletions(-)
diff --git a/packages/taler-util/src/clk.test.ts b/packages/taler-util/src/clk.test.ts
@@ -295,3 +295,42 @@ test("CLK-16: a mark is still shown in front of the shortened help", (t) => {
assert.ok(out.includes("[legacy] An old one."));
assert.ok(!out.includes("Kept around"));
});
+
+test("CLK-24: long help is wrapped into the description column", (t) => {
+ const prog = clk.program("w1");
+ prog
+ .maybeOption("opt", ["--opt"], clk.STRING, {
+ help: "One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen.",
+ })
+ .action(() => {});
+ const out = captureHelp(() => prog.run(["prog", "--help"]));
+ const lines = out.split("\n");
+ for (const line of lines) {
+ assert.ok(line.length <= 80, `too long: ${line}`);
+ }
+ // Continuation lines line up under the first line's description.
+ const start = lines.findIndex((l) => l.includes("--opt=VALUE"));
+ assert.strictEqual(lines[start].indexOf("One"), 25);
+ assert.strictEqual(lines[start + 1].search(/\S/), 25);
+});
+
+test("CLK-25: a long description paragraph is wrapped", (t) => {
+ const long = "Sentence one. " + "word ".repeat(60).trim() + ".";
+ const prog = clk.program("w2");
+ prog.subcommand("sub", "sub", { help: long }).action(() => {});
+ const out = captureHelp(() => prog.run(["prog", "sub", "--help"]));
+ for (const line of out.split("\n")) {
+ assert.ok(line.length <= 80, `too long: ${line}`);
+ }
+ assert.ok(out.includes("Sentence one."));
+});
+
+test("CLK-26: a word longer than the width is not broken", (t) => {
+ const uri = "taler://withdraw/" + "x".repeat(90);
+ const prog = clk.program("w3");
+ prog
+ .maybeOption("opt", ["--opt"], clk.STRING, { help: `Use ${uri} here.` })
+ .action(() => {});
+ const out = captureHelp(() => prog.run(["prog", "--help"]));
+ assert.ok(out.includes(uri));
+});
diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts
@@ -22,6 +22,7 @@ import {
processArgv,
readlinePrompt,
pathBasename,
+ terminalWidth,
} from "#compat-impl";
import { AmountString } from "./types-taler-common.js";
@@ -129,16 +130,59 @@ export namespace clk {
return { key: opt.substring(0, idx), value: opt.substring(idx + 1) };
}
+ /**
+ * Width to wrap help output at.
+ *
+ * Capped, because a description running the full width of a very wide
+ * terminal is hard to read even though it fits, and floored so that the
+ * indented description column keeps a usable amount of room.
+ */
+ function helpWidth(): number {
+ const w = terminalWidth();
+ if (w == null || w < 40) {
+ return 80;
+ }
+ return Math.min(w, 100);
+ }
+
+ /**
+ * Break a text into lines that fit into `width` when indented by
+ * `indent`. A word that doesn't fit on its own is left to overflow
+ * rather than broken, so that URIs and flag names stay selectable.
+ */
+ function wrapText(text: string, width: number, indent: number): string[] {
+ const lines: string[] = [];
+ let current = "";
+ for (const word of text.split(/\s+/).filter((w) => w !== "")) {
+ if (current === "") {
+ current = word;
+ } else if (indent + current.length + 1 + word.length <= width) {
+ current += " " + word;
+ } else {
+ lines.push(current);
+ current = word;
+ }
+ }
+ if (current !== "") {
+ lines.push(current);
+ }
+ return lines;
+ }
+
function formatListing(key: string, value?: string): string {
const res = " " + key;
if (!value) {
return res;
}
- if (res.length >= 25) {
- return res + "\n" + " " + value;
- } else {
- return res.padEnd(24) + " " + value;
+ // A key that reaches into the description column gets a line of its own.
+ const ownLine = res.length >= 25;
+ const indent = ownLine ? 4 : 25;
+ const pad = " ".repeat(indent);
+ const body = wrapText(value, helpWidth(), indent).join("\n" + pad);
+ if (ownLine) {
+ return res + "\n" + pad + body;
}
+ return res.padEnd(24) + " " + body;
}
/**
@@ -435,12 +479,14 @@ export namespace clk {
console.log(`Usage: ${usageSpec}`);
const ownMark = this.scArgs.mark;
if (this.scArgs.help || ownMark) {
+ const description = [
+ ownMark ? `[${ownMark}]` : undefined,
+ this.scArgs.help,
+ ]
+ .filter((x) => x)
+ .join(" ");
console.log();
- console.log(
- [ownMark ? `[${ownMark}]` : undefined, this.scArgs.help]
- .filter((x) => x)
- .join(" "),
- );
+ console.log(wrapText(description, helpWidth(), 0).join("\n"));
}
// Only when something has been documented: without help texts this
// would just repeat the usage line.
diff --git a/packages/taler-util/src/compat.d.ts b/packages/taler-util/src/compat.d.ts
@@ -21,3 +21,4 @@ export function pathBasename(s: string): string;
export function setUnhandledRejectionHandler(h: (e: any) => void): void;
export function getenv(name: string): string | undefined;
export function readFile(fileName: string): string;
+export function terminalWidth(): number | undefined;
diff --git a/packages/taler-util/src/compat.missing.ts b/packages/taler-util/src/compat.missing.ts
@@ -60,3 +60,8 @@ export function getenv(name: string): string | undefined {
export function readFile(fileName: string): string {
return notImplemented("readFile");
}
+
+export function terminalWidth(): number | undefined {
+ // Like getenv: "unknown" is a meaningful answer, so don't throw.
+ return undefined;
+}
diff --git a/packages/taler-util/src/compat.node.ts b/packages/taler-util/src/compat.node.ts
@@ -62,3 +62,7 @@ export function getenv(name: string): string | undefined {
export function readFile(fileName: string): string {
return fs.readFileSync(fileName, "utf-8");
}
+
+export function terminalWidth(): number | undefined {
+ return process.stdout.columns;
+}
diff --git a/packages/taler-util/src/compat.qtart.ts b/packages/taler-util/src/compat.qtart.ts
@@ -55,3 +55,8 @@ export function getenv(name: string): string | undefined {
export function readFile(fileName: string): string {
throw new Error("readFile not yet supported in qtart");
}
+
+export function terminalWidth(): number | undefined {
+ // No way to ask for it, so callers fall back to their default.
+ return undefined;
+}