comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
159 lines • 8.09 kB
JavaScript
/**
* Laying out terminal text whose width you no longer control.
*
* THE PROBLEM. Every aligned thing this process prints was padded with `String.length`: the two-column
* `--help` screen, the colon-aligned label blocks in the connect and tunnel banners, the
* `════` rules that fence them. That was correct for exactly as long as every string was
* ASCII, because for ASCII the two numbers are equal.
*
* They stop being equal the moment the same line is Korean, Japanese or Chinese. Those
* characters are East Asian Wide: one code unit, TWO cells. Padding a 6-character Korean
* label to column 15 with `15 - s.length` therefore leaves it 6 cells too wide, and every
* line under it steps to the right — the exact "the box visibly broke" failure, and one
* that is invisible on a developer machine reading English.
*
* Combining marks go the other way. Arabic and Persian diacritics render INTO the preceding
* cell and occupy none of their own, so counting them pads too little.
*
* This is a deliberately narrow subset of UAX #11 — the Wide/Fullwidth blocks that can
* actually appear in the twelve locales we ship, plus zero for the marks and format
* characters. It is not a general `wcwidth` and does not need to be. Ambiguous-width
* characters (`—`, `•`, and the `═` box rule itself) are treated as ONE cell, which is what
* every terminal outside a CJK legacy locale does — and what the current banners already
* assume, since they draw their rules by repeating `═` a fixed number of times.
*/
/**
* East Asian Wide + Fullwidth. Ranges, not a property escape: ECMAScript exposes
* `\p{Script=Hangul}` but NOT `\p{East_Asian_Width=Wide}`, so there is nothing to ask.
*/
const WIDE = [
[0x1100, 0x115f], // Hangul Jamo (initial consonants)
[0x2e80, 0x303e], // CJK Radicals … CJK Symbols. Stops before U+303F, which is narrow.
[0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, Hangul Compat Jamo, CJK compat
[0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
[0x4e00, 0x9fff], // CJK Unified Ideographs
[0xa000, 0xa4cf], // Yi
[0xa960, 0xa97f], // Hangul Jamo Extended-A
[0xac00, 0xd7a3], // Hangul Syllables — the bulk of Korean text
[0xf900, 0xfaff], // CJK Compatibility Ideographs
[0xfe10, 0xfe19], // Vertical forms
[0xfe30, 0xfe6f], // CJK Compatibility Forms, small form variants
[0xff00, 0xff60], // Fullwidth ASCII forms
[0xffe0, 0xffe6], // Fullwidth signs
[0x20000, 0x3fffd], // CJK Extension B and beyond
];
/** Marks that render into the previous cell, plus format characters (ZWJ, bidi marks). */
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u;
/**
* Emoji that default to the two-cell colour form, asked as a PROPERTY rather than as ranges.
*
* Ranges are the wrong tool here and were wrong when first written: the emoji blocks are
* interleaved with narrow symbols, so a hand-picked span like `1F300–1F64F` silently makes
* 🚀 (U+1F680) one cell and ✅ (U+2705, which is not in any emoji block at all) one cell,
* while the property gets both right. It also draws the line in exactly the place the
* banners need it: `✓`, `—`, `•` and `═` are text-presentation symbols, stay one cell, and
* so keep drawing the rules at the width this module promises.
*/
const EMOJI_WIDE = /^\p{Emoji_Presentation}$/u;
/** Terminal cells a string occupies. */
export function displayWidth(text) {
let width = 0;
// Iterating the string (not indexing it) yields whole code points, so an astral
// character counts once rather than twice for its surrogate halves.
for (const ch of text) {
const cp = ch.codePointAt(0);
if (cp === undefined)
continue;
if (ZERO_WIDTH.test(ch))
continue;
let wide = EMOJI_WIDE.test(ch);
if (!wide) {
for (const [lo, hi] of WIDE) {
if (cp >= lo && cp <= hi) {
wide = true;
break;
}
}
}
width += wide ? 2 : 1;
}
return width;
}
/**
* Pad `text` so the next column starts at `width` cells, never closer than `min`.
*
* `min` is what keeps an over-long entry readable instead of glued to its neighbour, and it
* is the reason this reproduces the hand-written help layout byte for byte: the existing
* screen already leaves three spaces after a description that overruns its column.
*/
export function padTo(text, width, min = 1) {
return text + " ".repeat(Math.max(min, width - displayWidth(text)));
}
/** Widest line in a block, measured in cells. Embedded newlines are split first. */
export function widestLine(lines) {
let widest = 0;
for (const line of lines) {
for (const part of line.split("\n"))
widest = Math.max(widest, displayWidth(part));
}
return widest;
}
/**
* The `════` boxes the CLI prints, drawn from their CONTENT rather than from a rule length
* typed in once and hoped to still fit.
*
* These blocks used to be literal arrays with a 68-character rule above and below. That was
* a guess even in English — the connect banner's longest line is 71 cells, so its rule has
* been three short of its own content for as long as it has existed — and a guess is not
* something translation survives: the same sentence in Japanese is a different number of
* cells, in either direction. Deriving the rule from the widest line means the box fits
* whatever language is inside it.
*
* The floor keeps every box at least as wide as it renders today, so no box gets NARROWER
* because a language is terser.
*
* The ceiling is 80 because that is the width a terminal box can actually assume, and the
* two failure modes are not symmetric. A line that overhangs its rule looks untidy; a RULE
* that overhangs the terminal wraps onto a second line and stops being a frame at all. The
* tunnel banner is the case that settles it: one of its lines is a URL plus a 48-character
* token plus the same token again, which no rule was ever going to contain.
*/
export const RULE_MIN = 68;
export const RULE_MAX = 80;
export function banner(title, body) {
const lines = body.filter((l) => l !== null);
const width = Math.min(RULE_MAX, Math.max(RULE_MIN, widestLine([` ${title}`, ...lines])));
const rule = "═".repeat(width);
return ["", rule, ` ${title}`, rule, ...lines, rule, ""].join("\n");
}
/**
* A colon-aligned label block (` Driving : http://…`).
*
* The column comes from the widest label PRESENT, in cells, so a translated label lands
* where its own text ends rather than where the English text used to. A value may contain
* newlines; continuation lines are indented to the value column by this helper, because
* that indent is a function of the translated label width and therefore cannot be baked
* into the string a translator writes.
*/
export function labelRows(indent, rows) {
// Callers build these lists conditionally (the connect banner's `Secure` row is only there
// for a remote pod), so an empty list is reachable. `Math.max()` of nothing is -Infinity
// and `" ".repeat(-Infinity)` throws — which would turn an empty section into a crash on
// the startup path, in the one module that must never be able to add a failure.
if (rows.length === 0)
return [];
const column = Math.max(...rows.map(([label]) => displayWidth(label))) + 1;
const hanging = " ".repeat(displayWidth(indent) + column + 2);
return rows.flatMap(([label, value]) => {
const [first, ...rest] = value.split("\n");
return [`${indent}${padTo(label, column, 1)}: ${first}`, ...rest.map((r) => hanging + r)];
});
}
/** ` 1. …` steps. Continuation lines hang under the text, not under the number. */
export function numberedSteps(steps) {
return steps.flatMap((step, i) => {
const [first, ...rest] = step.split("\n");
return [` ${i + 1}. ${first}`, ...rest.map((r) => ` ${r}`)];
});
}
//# sourceMappingURL=terminal-layout.js.map