diff --git a/CLAUDE.md b/CLAUDE.md index 4387faa..65439e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,8 @@ The version gate: every runtime-touching command first checks the runtime `mx.js **Bin** (runtime-wide utility executables at `/bin/`, meant for `PATH`) — `mx bin` (alias `mx bins`): `ls` (list bins with an mx-shipped vs user-added tag, flag non-executable ones, and note whether `bin/` is on `PATH`; porcelain returns `{dir, onPath, bins[]}`) · `path` (print the `bin/` dir for `export PATH="$(mx bin path):$PATH"`). mx ships `dcs`/`lcs` (delete/list Claude Code sessions by name) from `templates/bin/`; shipped bins are **re-stamped (overwritten) on every `init`/`sync`** like the runtime `CLAUDE.md` (so updates land), while **user-added bins are never touched**. To customize a shipped bin, copy it to a new name. Distinct from a work's `scripts/` (per-work). Bare `mx bin` defaults to `ls`. +**Divider** — `mx divider [-o]`: fill a terminal with `` as large block letters, a visual separator for macOS Mission Control Spaces (e.g. an `IN REVIEWS` window between work-window clusters). Bare = take over the current terminal and hold the banner (Ctrl-C/`q` to quit); `-o` = open a new fullscreen Terminal running it (macOS; `UNSUPPORTED` elsewhere). Auto-scales to the window via a zero-dep 5x7 block font (`renderBanner` in `@mx/core`); touches no runtime state. + ## Hooks (what they are) A **hook** is a user-owned script mx runs at a lifecycle moment. As of v3 they're **centralized** in a single `/hooks/` directory — **one executable per event**, named exactly for the event (no extension): `pre-worktree-create`, `post-worktree-create`, `pre/post-worktree-remove`, `pre/post-work-archive`, `pre/post-work-unarchive`, `pre/post-repo-fetch`, `repo-health`, `work-health`, and `session-prompt`. This replaces v2's per-repo `hydrate.sh`/`health.sh` and per-work `hooks/`. diff --git a/README.md b/README.md index 7ad7c3c..37bb310 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Domain logic lives in `packages/core` (`@mx/core`) as pure functions that return | `mx work health` / `mx work -n health` | local-only work-folder audit: stray files in the work root, worktree presence vs work.json, cross-work port collisions, archive invariants; augmented by the central `work-health` hook (`--all` includes archived) | | `mx health [--all]` | whole-runtime overview: every repo's health + every active work's health in one view (`--all` includes archived works) | | `mx mission-control [--port ] [-o]` (alias `mx mc`) | start a local, read-only **live web dashboard** — calm monochrome view of all repo/work health and a consolidated ports board (port → service → worktree → work → URL), streamed over SSE. `-o` opens the browser | +| `mx divider [-o]` | fill a terminal with `` as large block letters (a visual separator for macOS Spaces); bare takes over the current terminal and holds, `-o` opens a new fullscreen Terminal | | `mx bin ls` / `mx bin path` (alias `mx bins`) | list the runtime's `bin/` utility executables (mx-shipped + your own); `path` prints the dir for `export PATH="$(mx bin path):$PATH"` | Read commands take `--porcelain` for stable JSON; mutations echo the resulting object; errors are `{"error","code"}` with a non-zero exit. `-n ` can be omitted when your cwd implies it (inside a work folder/worktree, or a pristine clone). @@ -124,6 +125,7 @@ See **[docs/release.md](docs/release.md)** for the full runbook, the `NPM_TOKEN` - [x] Central hook hub (`/hooks/`): worktree create/remove, work archive/unarchive, repo fetch, repo-health, work-health, session-prompt — any language, branch on `MX_*` - [x] Health: `mx repo health`, `mx work health`, and `mx health` (whole-runtime overview) - [x] `mx mission-control` — live web dashboard (zero-dep server + single-file React/Vite/Tailwind UI) +- [x] `mx divider [-o]` — fill a terminal with large block text as a visual separator for macOS Spaces - [x] Multiple worktrees of one repo per work (named worktrees; `wt/`) - [x] `mx work open` / `new -o` — fullscreen Terminal (macOS) that resumes-or-creates the per-work Claude session, with a `session-prompt` hook for the initial prompt - [x] Env-based runtime discovery (`--runtime` / `$MX_RUNTIME` / default `~/mx`) diff --git a/apps/cli/src/divider.ts b/apps/cli/src/divider.ts new file mode 100644 index 0000000..41d8b99 --- /dev/null +++ b/apps/cli/src/divider.ts @@ -0,0 +1,60 @@ +import { renderBanner } from '@mx/core'; + +/** ANSI: clear the screen and scrollback, and move the cursor home. */ +const CLEAR = '\x1b[2J\x1b[3J\x1b[H'; +/** ANSI: hide the cursor. */ +const HIDE_CURSOR = '\x1b[?25l'; +/** ANSI: show the cursor. */ +const SHOW_CURSOR = '\x1b[?25h'; + +/** + * Fill the current terminal with `text` as large block letters and **hold** it + * on screen until the user quits (Ctrl-C or `q`), so the terminal reads as a + * labelled divider. Re-renders on window resize (e.g. after the window is made + * fullscreen). Used by `mx divider`. + * + * When stdout is not a TTY (piped/redirected), it prints the banner once at a + * default size and returns, rather than holding. + * + * @param text - The label to display. + */ +export function runDivider(text: string): void { + const paint = (): void => { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + process.stdout.write(CLEAR + renderBanner(text, cols, rows)); + }; + + // Non-interactive (piped) stdout: emit once and return, no hold. + if (!process.stdout.isTTY) { + process.stdout.write(renderBanner(text, process.stdout.columns || 80, process.stdout.rows || 24) + '\n'); + return; + } + + process.stdout.write(HIDE_CURSOR); + paint(); + process.stdout.on('resize', paint); // keep filling the window as it resizes + + /** Restore the terminal and exit cleanly. */ + const quit = (): void => { + process.stdout.write(CLEAR + SHOW_CURSOR); + process.exit(0); + }; + process.on('SIGINT', quit); + process.on('SIGTERM', quit); + + // Raw mode keeps typed keys from echoing over the banner; we watch for Ctrl-C + // (0x03) and `q`/`Q` to quit, and resume stdin so the process stays alive. + if (process.stdin.isTTY) { + try { + process.stdin.setRawMode(true); + } catch { + // Not all TTYs support raw mode; fall back to cooked mode + SIGINT. + } + } + process.stdin.resume(); + process.stdin.on('data', (d: Buffer) => { + const b = d[0]; + if (b === 0x03 || b === 0x71 || b === 0x51) quit(); // Ctrl-C, q, Q + }); +} diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 78e833c..dde8272 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -54,6 +54,9 @@ Bin (runtime-wide utility executables in /bin, meant for PATH): mx bin ls (alias mx bins) list bins (mx-shipped + your own); shows whether bin/ is on 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) + 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 6fab0f6..ccd9deb 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { MxError } from '@mx/core'; import { parseArgs } from './args'; -import { setPorcelain, emit, fail } from './output'; +import { setPorcelain, emit, fail, check, bold, dim } from './output'; import { HELP } from './help'; import { runGlobal } from './commands/global'; import { dispatchRepo } from './commands/repo'; @@ -11,6 +11,8 @@ import { dispatchWork } from './commands/work'; import { dispatchBin } from './commands/bin'; import { dispatchHealth } from './commands/health'; import { dispatchMissionControl } from './commands/missionControl'; +import { runDivider } from './divider'; +import { openFullscreenTerminal, shq } from './open'; /** * CLI version, surfaced by `mx version` / `mx --version`. Read from the @@ -67,6 +69,26 @@ export function main(): void { case 'mission-control': case 'mc': return dispatchMissionControl(positionals, flags); + case 'divider': { + // 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'); + if (flags.open) { + // Re-invoke this exact CLI (node + entry script) in the new Terminal, + // without -o, so it clears + renders + holds there. + openFullscreenTerminal( + `${shq(process.execPath)} ${shq(process.argv[1])} divider ${shq(text)}`, + ); + emit( + () => console.log(`${check()} opened divider ${bold(text)} ${dim('(new Terminal)')}`), + { divider: text, opened: true }, + ); + return; + } + return runDivider(text); + } default: throw new MxError(`unknown command: ${positionals[0]}`, 'BAD_ARGS'); } diff --git a/apps/cli/src/open.ts b/apps/cli/src/open.ts index 84335a3..27c5fd5 100644 --- a/apps/cli/src/open.ts +++ b/apps/cli/src/open.ts @@ -69,15 +69,27 @@ export interface OpenWorkOpts { * @param opts - Optional launch command to run in the new Terminal (e.g. a `claude` invocation). */ export function openWorkLayout(workdir: string, opts: OpenWorkOpts = {}): void { + // The shell command Terminal runs: cd into the work folder, then optionally + // chain the caller's launch command (e.g. `claude --resume `). + openFullscreenTerminal(`cd ${shq(workdir)}${opts.command ? ` && ${opts.command}` : ''}`); +} + +/** + * macOS-only: open a **new fullscreen Terminal window** running `command`. The + * low-level primitive behind `mx work open` (which prefixes a `cd`) and + * `mx divider` (which runs the banner renderer). Throws `UNSUPPORTED` off macOS + * and `OSASCRIPT` if the AppleScript step fails. + * + * @param command - The shell command to run in the new Terminal window. + */ +export function openFullscreenTerminal(command: string): void { if (process.platform !== 'darwin') { throw new MxError('-o/--open is only supported on macOS', 'UNSUPPORTED'); } - // The shell command Terminal runs: cd into the work folder, then optionally - // chain the caller's launch command (e.g. `claude --resume `). - const shellCmd = `cd ${shq(workdir)}${opts.command ? ` && ${opts.command}` : ''}`; + const shellCmd = command; - // Open a new Terminal window cd'd into the work folder, then fullscreen it. + // Open a new Terminal window running the command, then fullscreen it. // When the frontmost Terminal window is already fullscreen, macOS's "prefer // tabs in full screen" setting forces `do script` to open a TAB rather than a // window. Detect that (window count didn't grow) and detach the tab into its diff --git a/docs/commands.md b/docs/commands.md index b8ba7a0..76b3256 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -429,6 +429,15 @@ Manage the runtime-wide `bin/` directory — utility executables shared across e The directory is created by `mx init` and refreshed by `mx sync`. mx-shipped bins are **mx-owned: re-stamped (overwritten) on every sync**, like the runtime `CLAUDE.md`, so improvements land automatically; **your own bins are never touched**. To customize a shipped bin without losing it on the next sync, copy it to a new name. This is distinct from a work's `scripts/` folder (scoped to one work) — `bin/` is runtime-wide. +### `mx divider [--open|-o]` + +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. +- **`-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. + ## Loading the context-registry index There is **no** `SessionStart` hook. mx through v2 stamped a per-work `.claude/settings.json` whose `SessionStart` hook printed `context/INDEX.json` into every session, but Claude Code caps hook output (~2KB) so a non-trivial index was silently truncated. v3 drops the hook; `mx work new` and `mx sync` no longer stamp it, and `mx migrate` removes a default-stamped one (a customized `settings.json` is kept with a warning). diff --git a/docs/history.md b/docs/history.md index 9228ccf..d0269cf 100644 --- a/docs/history.md +++ b/docs/history.md @@ -2,6 +2,13 @@ What each release brought. Reverse-chronological. Dates reflect when the corresponding tag was pushed. +## 3.6.0 — 2026-07-08 + +**`mx divider [-o]`** — fill a terminal with `` as large block letters, a visual separator for macOS Mission Control Spaces (e.g. an `IN REVIEWS` / `PR REVIEWS` window between clusters of work windows). Bare, it takes over the current terminal, draws the banner, and holds it (Ctrl-C or `q` to quit); `-o` opens a new fullscreen Terminal running the same banner (macOS). The text auto-scales to fill the terminal and re-renders on resize. + +- **Zero-dependency renderer:** a 5x7 block font in `@mx/core` (`renderBanner(text, cols, rows)`) — no `figlet`/`banner` binary needed. The CLI (`divider.ts`) owns the clear/hold/cursor/resize handling; `openFullscreenTerminal` (factored out of `openWorkLayout`) powers `-o`. +- Personal window-organization aid; touches no runtime state. Minor — runtime stays v3, no migration. + ## 3.5.0 — 2026-07-06 **Runtime-wide `files/` store.** A new `/files/` directory: a free-form place for operational **values** any work session can read — credentials, cluster names/URLs, usernames, API keys/tokens, endpoints, and other key-value meta a task needs to actually operate (a Playwright login, an authenticated API call, an ssh target). Created **empty** by `mx init` and backfilled by `mx sync`; mx only guarantees the directory exists and never reads, writes, or validates its contents — the layout is the agent's/user's to choose. diff --git a/npm/README.md b/npm/README.md index dbfa742..cc5d412 100644 --- a/npm/README.md +++ b/npm/README.md @@ -60,6 +60,7 @@ Inside a work folder or worktree you can drop `-n` — mx infers the work/repo f | `mx work health` / `mx work -n health` | local-only work-folder audit (stray files, worktree presence, cross-work port collisions, archive invariants); augmented by the central `work-health` hook (`--all` includes archived) | | `mx health [--all]` | whole-runtime overview: every repo's health + every active work's health | | `mx bin ls` / `mx bin path` (alias `mx bins`) | list the runtime's `bin/` utility executables (mx-shipped + your own); `path` prints the dir for `export PATH="$(mx bin path):$PATH"` | +| `mx divider [-o]` | fill a terminal with `` as large block letters (a visual separator for macOS Spaces); `-o` opens a new fullscreen Terminal (macOS) | ## License diff --git a/npm/package.json b/npm/package.json index 518aa75..5e29d0e 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@roulabs/mx", - "version": "3.5.0", + "version": "3.6.0", "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 new file mode 100644 index 0000000..97adbf7 --- /dev/null +++ b/packages/core/src/banner.ts @@ -0,0 +1,133 @@ +/** + * A tiny zero-dependency block-letter renderer, used by `mx divider` to fill a + * terminal with large text as a visual separator between Mission Control Spaces. + * + * Each glyph is a 5-wide by 7-tall bitmap (`#` = filled, space = empty). The + * renderer composes the text, scales it up to roughly fill the given terminal + * size (correcting for the ~2:1 height-to-width of a character cell), and + * centers it. Rendering is pure — it takes the terminal dimensions as arguments + * and returns a string — so the CLI layer owns all the terminal I/O (clearing, + * cursor, holding the banner on screen). + */ + +/** + * Height (rows) of every glyph in {@link FONT}. + */ +const GLYPH_H = 7; + +/** + * 5x7 block-font glyphs keyed by uppercase character. `#` marks a filled cell, + * a space an empty one; every row is exactly 5 characters wide. Unknown + * characters fall back to space (or `?` when the character isn't whitespace). + */ +const FONT: Record = { + ' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '], + A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'], + B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '], + C: [' ####', '# ', '# ', '# ', '# ', '# ', ' ####'], + D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '], + E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'], + F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '], + G: [' ####', '# ', '# ', '# ##', '# #', '# #', ' ####'], + H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'], + I: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', '#####'], + J: ['#####', ' # ', ' # ', ' # ', ' # ', '# # ', ' ## '], + K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'], + L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'], + M: ['# #', '## ##', '# # #', '# #', '# #', '# #', '# #'], + N: ['# #', '## #', '# # #', '# ##', '# #', '# #', '# #'], + O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '], + P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '], + Q: [' ### ', '# #', '# #', '# #', '# # #', '# # ', ' ## #'], + R: ['#### ', '# #', '# #', '#### ', '# # ', '# # ', '# #'], + S: [' ####', '# ', '# ', ' ### ', ' #', ' #', '#### '], + T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '], + U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '], + V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '], + W: ['# #', '# #', '# #', '# #', '# # #', '## ##', '# #'], + X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'], + Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '], + Z: ['#####', ' # ', ' # ', ' # ', '# ', '# ', '#####'], + '0': [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '], + '1': [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'], + '2': [' ### ', '# #', ' # ', ' # ', ' # ', '# ', '#####'], + '3': ['#####', ' # ', ' # ', ' ### ', ' # ', '# #', ' ### '], + '4': [' # ', ' ## ', ' # # ', '# # ', '#####', ' # ', ' # '], + '5': ['#####', '# ', '#### ', ' #', ' #', '# #', ' ### '], + '6': [' ### ', '# ', '# ', '#### ', '# #', '# #', ' ### '], + '7': ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '], + '8': [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '], + '9': [' ### ', '# #', '# #', ' ####', ' #', ' #', ' ### '], + '-': [' ', ' ', ' ', '#####', ' ', ' ', ' '], + '&': [' ## ', '# # ', '# # ', ' ## ', '# ##', '# # ', ' ## #'], + '/': [' #', ' #', ' # ', ' # ', ' # ', '# ', '# '], + '.': [' ', ' ', ' ', ' ', ' ', ' ## ', ' ## '], + ':': [' ', ' ## ', ' ## ', ' ', ' ## ', ' ## ', ' '], + '!': [' # ', ' # ', ' # ', ' # ', ' # ', ' ', ' # '], + '?': [' ### ', '# #', ' # ', ' # ', ' # ', ' ', ' # '], +}; + +/** + * Resolve the glyph for a character, falling back to a blank for spaces/unknown + * whitespace and to `?` for any other unsupported character. + * + * @param ch - A single (already uppercased) character. + * @returns The 7-row glyph bitmap. + */ +function glyphFor(ch: string): string[] { + if (FONT[ch]) return FONT[ch]; + return ch.trim() === '' ? FONT[' '] : FONT['?']; +} + +/** + * 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. + * + * @param text - The label to render (case-insensitive; unsupported chars become `?`). + * @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); + + // 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(' ')); + } + + // Pick a scale that fits both dimensions. A character cell is about twice as + // tall as wide, so we aim for xscale ≈ 2·yscale to keep letters looking + // square, but never exceed the width/height budget (each axis is clamped + // independently, so long text scales down instead of overflowing). + 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 yscale = Math.max(1, Math.min(yMax, Math.floor(xMax / 2))); + const xscale = Math.max(1, Math.min(xMax, yscale * 2)); + + // 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; + + // Center vertically and horizontally, padding out 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))); + for (let i = 0; i < padTop; i++) out.push(''); + for (const l of scaled) out.push(l.trim() === '' ? '' : padLeft + l); + while (out.length < rows) out.push(''); + return out.join('\n'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 911e2b8..1e14e8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,8 @@ export type { InitResult, SyncResult, MxConfig, HookEvent, RuntimeBin, RepoConfi export { claudeProjectDirName, readSessionTitle, findSessionsByName } from './claudeSessions'; export type { ClaudeSession } from './claudeSessions'; +export { renderBanner } from './banner'; + export { compareVersions, maxVersion } from './semver'; export { migrateRuntime } from './migrations'; diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index e182ee5..59f1895 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -54,6 +54,7 @@ import { readSessionTitle, findSessionsByName, parseInitWorktreeSpec, + renderBanner, } from '../src/index'; import { compareVersions, maxVersion } from '../src/semver'; import { resolveBase } from '../src/git'; @@ -651,6 +652,39 @@ describe('worktreeSetBranch', () => { }); }); +describe('renderBanner (mx divider)', () => { + it('returns exactly `rows` lines, none wider than `cols`', () => { + const rows = 24; + const cols = 80; + const lines = renderBanner('IN REVIEWS', cols, rows).split('\n'); + expect(lines).toHaveLength(rows); + expect(Math.max(...lines.map((l) => [...l].length))).toBeLessThanOrEqual(cols); + }); + + it('draws the text with block characters', () => { + const out = renderBanner('HI', 80, 24); + expect(out).toContain('█'); + }); + + it('is blank for empty text (no block characters)', () => { + const out = renderBanner('', 80, 24); + expect(out).not.toContain('█'); + expect(out.split('\n')).toHaveLength(24); + }); + + it('does not throw on unsupported characters (fall back to ?)', () => { + expect(() => renderBanner('A~B', 80, 24)).not.toThrow(); + expect(renderBanner('A~B', 80, 24)).toContain('█'); + }); + + it('scales down to still fit a very small terminal', () => { + const cols = 20; + const rows = 10; + const lines = renderBanner('PR REVIEWS', cols, rows).split('\n'); + expect(lines).toHaveLength(rows); + }); +}); + describe('parseInitWorktreeSpec (mx work new initial worktrees)', () => { it('parses a bare repo with no branch or base', () => { expect(parseInitWorktreeSpec('app')).toEqual({ repo: 'app' });