Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Bin (runtime-wide utility executables in <runtime>/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 <text> [-o] fill the terminal with <text> as large block letters and hold it (Ctrl-C / q to quit); -o opens a new fullscreen Terminal (macOS)
mx divider <text> [-o] fill the terminal with <text> 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 <name> selector may be omitted when your cwd implies it: inside a work folder or
worktree (works/<work>/...) the work is inferred; inside repos/<repo>/... the repo is inferred.
Expand Down
14 changes: 9 additions & 5 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text> [-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 <text> [-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;
Expand Down
3 changes: 2 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,8 @@ The directory is created by `mx init` and refreshed by `mx sync`. mx-shipped bin

Fill a terminal with `<text>` 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.
Expand Down
4 changes: 4 additions & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
99 changes: 65 additions & 34 deletions packages/core/src/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
16 changes: 16 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading