From 9aba074d7348fbca521531b74768f016aff79219 Mon Sep 17 00:00:00 2001 From: Rousan Ali Date: Thu, 9 Jul 2026 15:41:52 +0530 Subject: [PATCH] fix: mx divider renders text literally with caller-controlled line breaks (3.6.2) Spaces are now kept verbatim (" MAIN " keeps its padding) instead of being collapsed, and the banner no longer auto-wraps on whitespace. Instead the caller controls stacking: a \n (or a real newline) starts a new line, so `mx divider "IN\nPROGRESS"` stacks the two words and fills the window like a single word, while "IN PROGRESS" stays one line. The CLI preserves the argument's spaces (no trim) and collapses a real newline to \n when re-launching under -o so the AppleScript command stays single-line. core: renderBanner literal rendering + \n line breaks. cli: divider arg handling, help. docs: commands, history. version 3.6.1 to 3.6.2. --- apps/cli/src/help.ts | 2 +- apps/cli/src/main.ts | 14 +++-- docs/commands.md | 3 +- docs/history.md | 4 ++ npm/package.json | 2 +- packages/core/src/banner.ts | 99 ++++++++++++++++++++++----------- packages/core/test/core.test.ts | 16 ++++++ 7 files changed, 98 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index dde8272..d497659 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -55,7 +55,7 @@ Bin (runtime-wide utility executables in /bin, meant for PATH): mx bin path print the bin/ dir: export PATH="$(mx bin path):$PATH" Divider (a fullscreen label to separate your Spaces): - mx divider [-o] fill the terminal with as large block letters and hold it (Ctrl-C / q to quit); -o opens a new fullscreen Terminal (macOS) + mx divider [-o] fill the terminal with as large block letters and hold it (Ctrl-C / q to quit); spaces are kept literal, use \n to stack lines; -o opens a new fullscreen Terminal (macOS) The -n selector may be omitted when your cwd implies it: inside a work folder or worktree (works//...) the work is inferred; inside repos//... the repo is inferred. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index ccd9deb..f59d9e2 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -73,16 +73,20 @@ export function main(): void { // Fill a terminal with big block text as a visual separator for your // Spaces. Bare: takes over the current terminal and holds. With -o: // opens a new fullscreen Terminal (macOS) running this same command. - const text = positionals.slice(1).join(' ').trim(); - if (!text) throw new MxError('usage: mx divider [-o]', 'BAD_ARGS'); + // Preserve the caller's spaces verbatim (no trim); validate on content. + const text = positionals.slice(1).join(' '); + if (!text.trim()) throw new MxError('usage: mx divider [-o]', 'BAD_ARGS'); if (flags.open) { // Re-invoke this exact CLI (node + entry script) in the new Terminal, - // without -o, so it clears + renders + holds there. + // without -o, so it clears + renders + holds there. Collapse any real + // newline to the literal `\n` sequence so the AppleScript `do script` + // command stays single-line (renderBanner treats `\n` as a break). + const safe = text.replace(/\n/g, '\\n'); openFullscreenTerminal( - `${shq(process.execPath)} ${shq(process.argv[1])} divider ${shq(text)}`, + `${shq(process.execPath)} ${shq(process.argv[1])} divider ${shq(safe)}`, ); emit( - () => console.log(`${check()} opened divider ${bold(text)} ${dim('(new Terminal)')}`), + () => console.log(`${check()} opened divider ${bold(text.replace(/\n/g, ' '))} ${dim('(new Terminal)')}`), { divider: text, opened: true }, ); return; diff --git a/docs/commands.md b/docs/commands.md index 76b3256..64124de 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -433,7 +433,8 @@ The directory is created by `mx init` and refreshed by `mx sync`. mx-shipped bin Fill a terminal with `` rendered as **large block letters**, as a visual separator for your macOS Mission Control Spaces (e.g. an `IN REVIEWS` or `PR REVIEWS` window between clusters of work windows). The text auto-scales to fill the terminal and re-renders when the window resizes. -- **Bare** (`mx divider "IN REVIEWS"`) — takes over the **current** terminal: clears it, draws the banner, and **holds** it on screen (Ctrl-C or `q` to quit). Piped/non-TTY output just prints the banner once and returns. +- **Rendered literally.** Spaces are kept verbatim (`" MAIN "` keeps its padding), and **you** control line breaks: a `\n` (or a real newline) starts a new stacked line. Nothing is auto-wrapped or collapsed — so to make a two-word label fill the window like a single word, stack it: `mx divider "IN\nPROGRESS"`. +- **Bare** (`mx divider "IN REVIEWS"`) — takes over the **current** terminal: clears it, draws the banner, and **holds** it on screen (Ctrl-C or `q` to quit). Piped/non-TTY output prints once and returns (honoring `COLUMNS`/`LINES` for size). - **`-o` / `--open` (macOS)** — opens a **new fullscreen Terminal** running the same banner, so you can drag it into place in the Spaces strip. Off macOS this is `UNSUPPORTED` (a warning). This is a personal window-organization aid; it touches no runtime state. The renderer is a zero-dependency 5x7 block font (`renderBanner` in `@mx/core`), so it needs no `figlet`/`banner` binary. diff --git a/docs/history.md b/docs/history.md index 46b7185..54a80f7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -2,6 +2,10 @@ What each release brought. Reverse-chronological. Dates reflect when the corresponding tag was pushed. +## 3.6.2 — 2026-07-09 + +**`mx divider` renders text literally, with caller-controlled line breaks.** Spaces are now kept verbatim (`" MAIN "` keeps its padding) instead of being collapsed, and the banner no longer auto-wraps on whitespace. Instead, **you** control stacking: a `\n` (or a real newline) starts a new line, so `mx divider "IN\nPROGRESS"` stacks the two words and fills the window like a single word, while `"IN PROGRESS"` stays one line. The CLI preserves the argument's spaces (no trim) and collapses a real newline to `\n` when re-launching under `-o` so the AppleScript stays single-line. Renderer change in `@mx/core` (`renderBanner`) + CLI arg handling. + ## 3.6.1 — 2026-07-09 **`mx divider` renders much larger.** The banner now scales to fill the terminal (chunky letters at the largest cell size the width allows, height grown to match) instead of strict proportional scaling, which left a long label like `IN REVIEWS` tiny on a fullscreen window (7 rows). Also honors `COLUMNS`/`LINES` env vars as a size fallback for non-TTY (piped) output, so you can preview a size without going fullscreen. Renderer-only change in `@mx/core` (`renderBanner`) plus the CLI size fallback. diff --git a/npm/package.json b/npm/package.json index 2f89cf0..9112d87 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@roulabs/mx", - "version": "3.6.1", + "version": "3.6.2", "description": "mx — run several features in parallel across shared repos using git worktrees", "type": "module", "bin": { diff --git a/packages/core/src/banner.ts b/packages/core/src/banner.ts index c990479..bd12b85 100644 --- a/packages/core/src/banner.ts +++ b/packages/core/src/banner.ts @@ -79,57 +79,88 @@ function glyphFor(ch: string): string[] { return ch.trim() === '' ? FONT[' '] : FONT['?']; } +/** + * The unscaled bitmap of one text line: its `GLYPH_H` rows and total cell width. + */ +interface LineBitmap { + /** `GLYPH_H` strings of `#`/space — the line's glyphs joined by a blank column. */ + rows: string[]; + /** Width of each row in base cells. */ + width: number; +} + +/** + * Build the unscaled bitmap for a single line, **rendered literally** — every + * character (including spaces) becomes a glyph, so the caller's spacing is + * preserved exactly. + * + * @param line - One (already uppercased) line of text. + * @returns Its base rows and width. + */ +function lineBitmap(line: string): LineBitmap { + const glyphs = [...line].map(glyphFor); + const rows: string[] = []; + for (let r = 0; r < GLYPH_H; r++) rows.push(glyphs.map((g) => g[r]).join(' ')); + return { rows, width: glyphs.length === 0 ? 0 : glyphs.length * 5 + (glyphs.length - 1) }; +} + /** * Render `text` as large block letters centered in a `cols` by `rows` terminal, - * scaled up to roughly fill the space. Returns exactly `rows` newline-separated - * lines (blank where empty) using `█` for filled cells, so the caller can clear - * the screen and print it as a full-screen banner. + * scaled up to fill the space. The text is rendered **literally**: spaces are + * preserved as-is (so ` MAIN ` keeps its padding), and **you** control line + * breaks — a newline, either a real one or the two-character sequence `\n`, + * starts a new stacked line. Nothing is auto-wrapped or collapsed. Returns + * exactly `rows` newline-separated lines using `█` for filled cells. * - * @param text - The label to render (case-insensitive; unsupported chars become `?`). + * @param text - The label to render (case-insensitive; unsupported chars become `?`; `\n` or a real newline stacks lines; spaces are kept verbatim). * @param cols - Terminal width in columns. * @param rows - Terminal height in rows. * @returns The composed banner as a single string of `rows` lines. */ export function renderBanner(text: string, cols: number, rows: number): string { - const chars = [...text.toUpperCase()]; - const glyphs = chars.map(glyphFor); - // Base bitmap width: each glyph is 5 cells wide, joined by a 1-cell gap. - const baseW = glyphs.length === 0 ? 0 : glyphs.length * 5 + (glyphs.length - 1); + // Treat the literal two-character sequence "\n" as a line break too, since a + // real newline is awkward to pass on a command line. + const lines = text.replace(/\\n/g, '\n').toUpperCase().split('\n'); + const blank = (): string => Array.from({ length: rows }, () => '').join('\n'); + if (lines.every((l) => l === '')) return blank(); - // Compose the 7 base rows (glyphs separated by a blank column). - const baseRows: string[] = []; - for (let r = 0; r < GLYPH_H; r++) { - baseRows.push(glyphs.map((g) => g[r]).join(' ')); - } + const bitmaps = lines.map(lineBitmap); + const maxW = Math.max(1, ...bitmaps.map((b) => b.width)); + const gap = 1; // blank base-rows between stacked lines + const totalBaseH = lines.length * GLYPH_H + (lines.length - 1) * gap; - // Scale up to fill the terminal. Width is the usual constraint (a long label - // caps how wide each cell can be), so take the largest cell width that fits - // and then grow the height to match — capped at the same factor so letters - // stay chunky (roughly square in cell counts) rather than proportional-but- - // tiny. This fills far more of a large fullscreen window than strict - // proportional scaling, which is what makes the banner readable at a glance. + // Scale up to fill the terminal, based on the widest line and the stacked + // height. Take the largest cell width the width allows, then grow the height + // to match (capped at the same factor so letters stay chunky, not needle + // thin). Since the caller controls wrapping, short lines fill the window. const marginH = 2; const marginV = 2; - const xMax = Math.floor((cols - marginH) / Math.max(1, baseW)); - const yMax = Math.floor((rows - marginV) / GLYPH_H); + const xMax = Math.floor((cols - marginH) / maxW); + const yMax = Math.floor((rows - marginV) / Math.max(1, totalBaseH)); const xscale = Math.max(1, xMax); const yscale = Math.max(1, Math.min(yMax, xscale)); - // Scale each base row up (horizontally by xscale, vertically by yscale). - const scaled: string[] = []; - for (const row of baseRows) { - let line = ''; - for (const cell of row) line += (cell === '#' ? '█' : ' ').repeat(xscale); - for (let k = 0; k < yscale; k++) scaled.push(line); - } - const lineW = baseW * xscale; + const lineW = maxW * xscale; + const padLeft = ' '.repeat(Math.max(0, Math.floor((cols - lineW) / 2))); + + // Compose the scaled block, each line centered within the widest line's box. + const block: string[] = []; + bitmaps.forEach((bm, i) => { + const leadCells = Math.floor((maxW - bm.width) / 2); + for (const row of bm.rows) { + const padded = ' '.repeat(leadCells) + row + ' '.repeat(maxW - bm.width - leadCells); + let line = ''; + for (const cell of padded) line += (cell === '#' ? '█' : ' ').repeat(xscale); + for (let k = 0; k < yscale; k++) block.push(line.trimEnd() === '' ? '' : padLeft + line); + } + if (i < lines.length - 1) for (let g = 0; g < gap * yscale; g++) block.push(''); + }); - // Center vertically and horizontally, padding out to exactly `rows` lines. + // Center vertically, then pad/clip to exactly `rows` lines. const out: string[] = []; - const padTop = Math.max(0, Math.floor((rows - scaled.length) / 2)); - const padLeft = ' '.repeat(Math.max(0, Math.floor((cols - lineW) / 2))); + const padTop = Math.max(0, Math.floor((rows - block.length) / 2)); for (let i = 0; i < padTop; i++) out.push(''); - for (const l of scaled) out.push(l.trim() === '' ? '' : padLeft + l); + out.push(...block); while (out.length < rows) out.push(''); - return out.join('\n'); + return out.slice(0, rows).join('\n'); } diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 59f1895..afa7d11 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -683,6 +683,22 @@ describe('renderBanner (mx divider)', () => { const lines = renderBanner('PR REVIEWS', cols, rows).split('\n'); expect(lines).toHaveLength(rows); }); + + it('stacks on a literal \\n and fills more height than the same text on one line', () => { + const filled = (s: string): number => + renderBanner(s, 200, 50).split('\n').filter((l) => l.includes('█')).length; + // Two stacked words use more vertical band than one wide single line. + expect(filled('IN\\nREVIEWS')).toBeGreaterThan(filled('IN REVIEWS')); + }); + + it('stacks on a real newline the same as the literal \\n', () => { + expect(renderBanner('A\nB', 120, 40)).toBe(renderBanner('A\\nB', 120, 40)); + }); + + it('respects spaces literally (does not collapse runs)', () => { + // Extra spaces widen the line, so at a fixed size the scale differs. + expect(renderBanner('A B', 60, 20)).not.toBe(renderBanner('A B', 60, 20)); + }); }); describe('parseInitWorktreeSpec (mx work new initial worktrees)', () => {