@prplkane/roundtable 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4
4
+
5
+ ### Terminal
6
+
7
+ - Report only the provider CLIs found on this machine: `roundtable doctor` lists missing ones
8
+ in a single line instead of a block each (`--vendor <name>` still shows the full block), and
9
+ foreground startup prints a `providers:` line in the seat colours instead of a failed quota
10
+ read for every CLI that is not installed.
11
+ - Always print how to open the board and that Ctrl+C stops it, including with `--no-open`.
12
+ - Say plainly when the port cannot be taken: in use, or a sandbox that forbids a local server
13
+ (`EPERM`), with the `roundtable server start` way out. The MCP tools and `roundtable server
14
+ start` report that last line within seconds instead of waiting out the readiness timeout,
15
+ while two hosts starting the board at once both get the one that won the port.
16
+ - Blink the block cursor beside the wordmark where the terminal allows blinking text, and show
17
+ vendor names in their seat colours.
18
+
3
19
  ## 0.6.3
4
20
 
5
21
  ### Assistant integration
package/README.md CHANGED
@@ -69,9 +69,10 @@ For a source snapshot before its npm release, use
69
69
  `npm install -g https://github.com/prplkane/roundtable/archive/refs/heads/main.tar.gz`.
70
70
 
71
71
  Interactive `roundtable --help`, `roundtable` and `roundtable fake` show the app's pixel logo
72
- tile in orchid, with the wordmark and installed version beside it. Narrow terminals and
73
- terminals without UTF-8 use a short title; `NO_COLOR=1` disables color. Redirected output,
74
- CI, dumb terminals, MCP and JSON commands omit the decoration.
72
+ tile in orchid, with the wordmark, a blinking cursor and the installed version beside it, then
73
+ the provider CLIs found on this machine in their seat colors. Narrow terminals and terminals
74
+ without UTF-8 use a short title; `NO_COLOR=1` disables color. Redirected output, CI, dumb
75
+ terminals, MCP and JSON commands omit the decoration.
75
76
 
76
77
  ### Try the demo
77
78
 
package/docs/workflow.md CHANGED
@@ -160,6 +160,23 @@ requested action needs them. There is no Roundtable cloud connection or API key
160
160
  Keep the provider CLI executables on the assistant's `PATH`; after changing a Node version or
161
161
  moving its installation, rerun setup and reload the assistant.
162
162
 
163
+ Some assistants run commands in a sandbox that forbids opening a local port. A `roundtable`
164
+ command run inside such a shell cannot start the board: it dies with `listen EPERM
165
+ 127.0.0.1:4747`, and the command reports that line. Codex on Linux has shown this. Check
166
+ first that the MCP connection itself is loaded (`codex mcp list`, then `/mcp` in Codex), since
167
+ the MCP tools are started by the assistant rather than by its sandboxed shell. If the tools
168
+ still cannot start the board, start it once from a normal terminal:
169
+
170
+ ```bash
171
+ roundtable server start
172
+ ```
173
+
174
+ It keeps running in the background and `roundtable server status` shows it; the tools then
175
+ only connect to it. Stop it with `roundtable server stop` when no review is active. If a tool
176
+ still cannot reach a board that is running, that sandbox blocks loopback connections too, and
177
+ the assistant cannot use Roundtable from inside it; report it as an issue with the assistant,
178
+ version and OS.
179
+
163
180
  To check the workflow without a provider account, use `roundtable setup --client codex --fake`
164
181
  before restarting the assistant and request a scripted demo review. This uses port 4748 and
165
182
  makes no model calls. Switch back with `roundtable setup --client codex --live` and reload again.
@@ -168,10 +185,12 @@ the board, or run `roundtable fake` for a foreground demo. If the board is alrea
168
185
  the background, open its URL instead of starting a second server.
169
186
 
170
187
  Foreground board startup and top-level help show the app's pixel logo tile in orchid, with
171
- the wordmark and installed version beside it. The tile is drawn with half-block characters,
172
- so it needs a UTF-8 terminal; other locales, and `ROUNDTABLE_ASCII=1`, get the wordmark
173
- alone. Narrow terminals use a short title, `NO_COLOR=1` removes color, and redirected
174
- output, CI, `TERM=dumb`, MCP and JSON commands omit it.
188
+ the wordmark, a blinking block cursor and the installed version beside it. The tile is drawn
189
+ with half-block characters, so it needs a UTF-8 terminal; other locales, and
190
+ `ROUNDTABLE_ASCII=1`, get the wordmark alone. Startup then lists the provider CLIs found on
191
+ `PATH`, each in its seat color; `roundtable doctor` names the missing ones in one line.
192
+ Narrow terminals use a short title, `NO_COLOR=1` removes color, and redirected output, CI,
193
+ `TERM=dumb`, MCP and JSON commands omit the decoration.
175
194
 
176
195
  ## 2. Install and log in to each vendor
177
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prplkane/roundtable",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Local decision reviews with coding-agent CLIs, human input, budget checks and Markdown records.",
5
5
  "keywords": [
6
6
  "claude",
@@ -16,7 +16,9 @@ one-time `roundtable setup --client codex` (or `claude` / `agy`) step.
16
16
  The MCP server starts the local board when needed; offer its link without opening it.
17
17
  For the CLI fallback, read `roundtable agent --help`, then `roundtable agent status --refresh`.
18
18
  If no server answers, use `roundtable server start` to start a hidden board and retry
19
- the read. An incompatible running board needs a restart after its reviews finish;
19
+ the read. If that reports `EPERM` or that the environment blocks local servers, your
20
+ sandbox forbids the port: ask the user to run `roundtable server start` in their own
21
+ terminal once, then retry. An incompatible running board needs a restart after its reviews finish;
20
22
  `roundtable server restart` refuses active reviews. Never automatically retry a write
21
23
  that might already have succeeded.
22
24
  2. Choose distinct perspectives using the user's facts and constraints. Normally use two seats,
package/src/doctor.js CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { ADAPTERS } from './adapters/config.js';
14
14
  import { resolveBinary, runProcess, profileHome, dataDir } from './adapters/spawn.js';
15
15
  import { dataDirectory } from './history.js';
16
+ import { paintVendor } from './terminal.js';
16
17
 
17
18
  // Shell lines that log a profile in, per vendor, for the platform the user is on. Pure.
18
19
  export function loginInstructions(vendor, profile, where = {}) {
@@ -46,9 +47,13 @@ async function main() {
46
47
  const catalogue = await vendorCatalogue(ADAPTERS.agy.terms && !acknowledgeTermsRisk ? { agyModels: async () => [] } : {});
47
48
  console.log(`roundtable doctor (${process.platform}, node ${process.version}, data dir ${dataDir()})`);
48
49
  console.log(live ? 'live: one trivial model call per vendor' : 'static only; add --live for one trivial model call per vendor');
50
+ const missing = []; // CLIs not on PATH: one line at the end, a full block only when asked for by --vendor
49
51
  for (const [vendor, a] of Object.entries(ADAPTERS)) {
50
52
  if (only && only !== vendor) continue;
51
- console.log(`\n== ${a.label} (${vendor}) pool: ${a.pool}`);
53
+ let bin = null, lookup = null;
54
+ if (a.kind === 'cli') try { bin = resolveBinary(a.binary, a.npmBin); } catch (e) { lookup = e.message; }
55
+ if (a.kind === 'cli' && !bin && only !== vendor) { missing.push(`${a.label} (${a.binary})`); continue; }
56
+ console.log(`\n== ${paintVendor(a)} (${vendor}) pool: ${a.pool}`);
52
57
  if (a.kind === 'api') { // no binary: everything about an endpoint seat is on its card
53
58
  console.log(' seat it on the board: base URL (…/v1), model id, and for a hosted API the NAME of the key variable');
54
59
  console.log(` efforts ${a.effortLevels.join(', ')} (reasoning_effort, sent only when set)`);
@@ -58,8 +63,7 @@ async function main() {
58
63
  }
59
64
  const held = !!a.terms && !acknowledgeTermsRisk;
60
65
  const heldLine = `held: nothing is sent to ${a.binary} without --acknowledge-terms-risk`;
61
- let bin = null;
62
- try { bin = resolveBinary(a.binary, a.npmBin); console.log(` binary ${bin.prefix.length ? `${bin.prefix[0]} (via node)` : bin.file}`); } catch (e) { console.log(` binary NOT FOUND: ${e.message}`); }
66
+ console.log(` binary ${bin ? bin.prefix.length ? `${bin.prefix[0]} (via node)` : bin.file : `NOT FOUND: ${lookup}`}`);
63
67
  if (bin) console.log(` version ${await version(bin, vendor)}`);
64
68
  const c = catalogue[vendor];
65
69
  if (held) console.log(` models ${heldLine}`);
@@ -88,6 +92,7 @@ async function main() {
88
92
  }
89
93
  if (profile) for (const l of loginInstructions(vendor, profile)) console.log(` profile ${l}`);
90
94
  }
95
+ if (missing.length) console.log(`\nnot installed: ${missing.join(', ')}; install and sign in to seat them, or add --vendor <name> for that block`);
91
96
  if (!profile) console.log('\nadd --profile <name> to see how to log a named account profile in for each vendor');
92
97
  }
93
98
 
@@ -1,5 +1,7 @@
1
1
  // A cheap, versioned health check; provider catalogues are not server readiness checks.
2
2
  import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, openSync, closeSync, readFileSync, rmSync } from 'node:fs';
4
+ import os from 'node:os';
3
5
  import { realpath } from 'node:fs/promises';
4
6
  import path from 'node:path';
5
7
  import { fileURLToPath } from 'node:url';
@@ -43,23 +45,49 @@ export async function inspectServer(config, { signal, allowOlder = false } = {})
43
45
  // Readiness reads may be retried. A review write is never part of this loop.
44
46
  export async function ensureServer(config, { signal, timeoutMs = 35000 } = {}) {
45
47
  const end = Date.now() + timeoutMs;
46
- let spawned = false, spawnFailed = false, last;
48
+ let spawned = false, spawnFailed = false, exited = null, ready = false, last;
49
+ // The board's stderr goes to a file, never a pipe: it outlives this process, and a pipe to a
50
+ // gone host would break its logging. The file sits in a fresh private temp directory (no
51
+ // predictable path for another user to plant), is read only when the board exits early, and
52
+ // goes once the board is ready (at once where an open file can be unlinked, else when the
53
+ // board exits while this process is still around).
54
+ let log = null;
55
+ const discard = () => { try { if (log) rmSync(path.dirname(log), { recursive: true, force: true }); } catch {} };
47
56
  while (Date.now() < end) {
48
57
  signal?.throwIfAborted();
49
58
  try {
50
59
  const status = await inspectServer(config, { signal });
51
- if (status) return status;
60
+ if (status) { if (spawned) { ready = true; discard(); } return status; }
52
61
  if (config.start === false) throw new Error('Roundtable is not running; start the local board or remove --no-start.');
53
62
  if (!spawned) {
54
63
  spawned = true;
55
64
  // Survives the host connection, so closing chat cannot cancel a review or its timer.
65
+ let fd = null;
66
+ try { log = path.join(mkdtempSync(path.join(os.tmpdir(), 'roundtable-start-')), 'stderr.log'); fd = openSync(log, 'wx'); } catch {}
56
67
  const child = spawn(process.execPath, [SERVER, '--no-open', ...(config.fake ? ['--fake'] : [])], {
57
68
  env: { ...process.env, PORT: String(config.port), ...(config.dataDir ? { ROUNDTABLE_DEBATES_DIR: config.dataDir } : {}) },
58
- detached: true, windowsHide: true, stdio: 'ignore',
69
+ detached: true, windowsHide: true, stdio: ['ignore', 'ignore', fd ?? 'ignore'],
59
70
  });
60
- child.once('error', () => { spawnFailed = true; }); child.unref();
71
+ if (fd != null) closeSync(fd);
72
+ child.once('error', () => { spawnFailed = true; });
73
+ child.once('exit', (code, sig) => { exited = { code, sig, at: Date.now() }; if (ready) discard(); });
74
+ child.unref();
61
75
  }
62
76
  } catch (e) { if (!e.transient) throw e; last = e; }
77
+ // A board that exits early may have lost the port or the data folder to one that another
78
+ // process started at the same moment (two hosts asking at once), so the port is still
79
+ // watched for a few seconds before the exit counts as the answer.
80
+ if (exited && Date.now() - exited.at > 5000) {
81
+ let tail = '';
82
+ // the board's own last word where it had one, else the message line of a crash, else the last line
83
+ try {
84
+ const lines = readFileSync(log, 'utf8').split(/\r?\n/).filter((l) => l.trim());
85
+ tail = (lines.findLast((l) => l.startsWith('[server]')) || lines.find((l) => /^\w*Error: /.test(l)) || lines.at(-1) || '').trim().slice(0, 200);
86
+ } catch {}
87
+ discard();
88
+ const sandboxed = /cannot listen.*\b(EPERM|EACCES)\b/.test(tail);
89
+ throw new Error(`The local board exited before it was ready${tail ? ` (${tail})` : ''}. ${sandboxed ? 'This environment blocks local servers: start the board from a normal terminal with roundtable server start, then retry.' : 'Run roundtable --no-open in a terminal for startup details.'} No review was started.`);
90
+ }
63
91
  if (spawnFailed) break;
64
92
  await delay(100, undefined, { signal });
65
93
  }
package/src/server.js CHANGED
@@ -29,7 +29,7 @@ import { pickFolder } from './folder-picker.js';
29
29
  import { dataDirectory, legacyHistory, acquireDataLock, checkedHistoryPath, removeHistory, createUsageStore, createHistoryIndex } from './history.js';
30
30
  import { VERSION } from './version.js';
31
31
  import { LOCAL_API_VERSION } from './local-server.js';
32
- import { terminalBanner } from './terminal.js';
32
+ import { terminalBanner, paintVendor } from './terminal.js';
33
33
 
34
34
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
35
  const UI = path.join(ROOT, 'src', 'ui');
@@ -402,6 +402,9 @@ function synthesizeDebate(d, body = {}) {
402
402
  const quota = {}; // pool key -> { windows, plan, at, groups? }
403
403
  const readers = FAKE ? { claude: async () => fakeQuota('claude'), codex: async () => fakeQuota('codex'), agy: async () => fakeQuota('agy') } : quotaReaders;
404
404
  const keyOf = (vendor, account) => poolKey(vendor, account, poolsByAccount(ADAPTERS[vendor]));
405
+ // A vendor whose CLI is not on PATH gets no quota read and no startup line: a CLI that is not
406
+ // here is not a failed read. Demo mode seats every vendor with scripted replies.
407
+ const installed = (vendor) => { if (FAKE || ADAPTERS[vendor]?.kind !== 'cli') return true; try { resolveBinary(ADAPTERS[vendor].binary, ADAPTERS[vendor].npmBin); return true; } catch { return false; } };
405
408
 
406
409
  function setQuota(key, q) {
407
410
  if (!q) return;
@@ -415,7 +418,7 @@ const reading = new Map();
415
418
  function refreshQuota(key, { acknowledged = false } = {}) {
416
419
  const vendor = poolVendor(key), account = poolAccount(key);
417
420
  // a reader that stopped itself (agy's latch, quota.js) said why once; no read, no repeat
418
- if (!readers[vendor] || readers[vendor].stopped?.() || termsHeld(vendor, acknowledged)) return Promise.resolve(null);
421
+ if (!readers[vendor] || readers[vendor].stopped?.() || termsHeld(vendor, acknowledged) || !installed(vendor)) return Promise.resolve(null);
419
422
  if (reading.has(key)) return reading.get(key);
420
423
  // The readers resolve null on every failure (timeout, spawn error, no result) rather than
421
424
  // throwing, so say so here or a dead read is invisible on the console. Resolves to the figure
@@ -423,7 +426,7 @@ function refreshQuota(key, { acknowledged = false } = {}) {
423
426
  const p = (async () => {
424
427
  try {
425
428
  const q = await readers[vendor]({ home: account ? profileHome(vendor, account) : null });
426
- if (!q) { diagnostics.write({ code: 'status-failed', action: 'quota', vendor }); console.warn(`[quota] ${key}: no figure (read failed or timed out); the strip keeps the last one`); }
429
+ if (!q) { diagnostics.write({ code: 'status-failed', action: 'quota', vendor }); console.warn(`[quota] ${paintVendor(ADAPTERS[vendor], { text: key })}: no figure (not logged in, or the read failed or timed out); the strip keeps the last one`); }
427
430
  setQuota(key, q);
428
431
  return q;
429
432
  } catch (e) { diagnostics.write({ code: 'status-failed', action: 'quota', vendor }, e); console.error(`[quota] ${key}: ${e.message}`); return null; } finally { reading.delete(key); }
@@ -876,15 +879,27 @@ function openBrowser(url) {
876
879
  const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
877
880
  try { spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref(); return true; } catch { return false; }
878
881
  }
882
+ // A port that cannot be taken is said plainly: in use, or a sandbox that forbids listening
883
+ // (Codex on Linux, for one), where the board has to be started from a normal terminal instead.
884
+ server.on('error', (e) => {
885
+ diagnostics.write({ code: 'server-start', action: 'startup' }, e);
886
+ const why = e.code === 'EADDRINUSE' ? `port ${PORT} is in use. Check roundtable server status, or start with --port`
887
+ : ['EPERM', 'EACCES'].includes(e.code) ? `this environment does not allow a local server on 127.0.0.1:${PORT} (${e.code}). Start the board from a normal terminal with roundtable server start; the assistant's tools then connect to it`
888
+ : e.message;
889
+ console.error(`[server] cannot listen: ${why}`);
890
+ process.exit(1);
891
+ });
879
892
  server.listen(PORT, '127.0.0.1', () => {
880
893
  const url = `http://localhost:${PORT}`;
881
894
  const open = process.stdout.isTTY && !process.argv.includes('--no-open') && process.env.ROUNDTABLE_NO_OPEN !== '1' && !process.env.CI;
882
895
  const opened = open && openBrowser(url);
883
896
  process.stdout.write(terminalBanner());
884
897
  console.log(`roundtable${FAKE ? ' (fake seats)' : ''}: ${url} token ${TOKEN}`);
885
- console.log(`mode: ${FAKE ? 'demo (scripted replies and usage)' : 'live (configured providers)'}; data: ${DEBATES_DIR}`);
898
+ console.log(`mode: ${FAKE ? 'demo (scripted replies and usage)' : 'live'}; data: ${DEBATES_DIR}`);
899
+ const found = Object.entries(ADAPTERS).filter(([v, a]) => a.kind === 'cli' && installed(v)).map(([, a]) => paintVendor(a));
900
+ if (!FAKE) console.log(found.length ? `providers: ${found.join(', ')} (CLIs on PATH; an API endpoint is seated on the board)` : 'providers: no Claude, Codex or Antigravity CLI on PATH; seat an API endpoint on the board, or install one and restart. roundtable doctor has details');
886
901
  console.log('diagnostics: _diagnostics in the data folder; use Save error log on the board. Nothing is uploaded.');
887
- if (opened) console.log('the board is open in your browser; Ctrl+C here stops the server and every CLI it started');
902
+ console.log(`${opened ? 'the board is open in your browser' : `open ${url} in your browser`}; Ctrl+C here stops the server and every CLI it started`);
888
903
  });
889
904
 
890
905
  // No CLI outlives the server: on a signal or an uncaught error every registered child goes
package/src/terminal.js CHANGED
@@ -12,17 +12,40 @@ const TILE = [
12
12
  ];
13
13
  const TAGLINE = 'Local decision reviews';
14
14
  const RESET = '\x1b[0m';
15
+ const BOLD = '\x1b[1m', DIM = '\x1b[2m', BLINK = '\x1b[5m';
15
16
 
16
17
  // Half blocks need a UTF-8 terminal. Windows consoles take UTF-16 from Node whatever the code
17
18
  // page; elsewhere the locale has to say so. ROUNDTABLE_ASCII=1 forces the plain form.
18
19
  const unicode = (env, platform) => env.ROUNDTABLE_ASCII !== '1'
19
20
  && (platform === 'win32' || /utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || ''));
20
- // Tile and ink colours: true colour where the terminal says so, the nearest 256-colour or
21
- // basic pair otherwise. Returned as [tile background, ink foreground, orchid foreground].
22
- function palette(env, platform) {
23
- if (/truecolor|24bit/i.test(env.COLORTERM || '') || env.WT_SESSION || platform === 'win32') return ['\x1b[48;2;199;154;208m', '\x1b[38;2;43;29;56m', '\x1b[38;2;199;154;208m'];
24
- if (env.TERM?.includes('256color')) return ['\x1b[48;5;182m', '\x1b[38;5;234m', '\x1b[38;5;182m'];
25
- return ['\x1b[45m', '\x1b[30m', '\x1b[35m'];
21
+ // Which colours the terminal takes: true colour where it says so, 256 colours, or the basic 8.
22
+ const tier = (env, platform) => (/truecolor|24bit/i.test(env.COLORTERM || '') || env.WT_SESSION || platform === 'win32') ? 'true'
23
+ : env.TERM?.includes('256color') ? '256' : 'basic';
24
+ // Tile and ink colours per tier: [tile background, ink foreground, orchid foreground].
25
+ const PALETTE = {
26
+ true: ['\x1b[48;2;199;154;208m', '\x1b[38;2;43;29;56m', '\x1b[38;2;199;154;208m'],
27
+ 256: ['\x1b[48;5;182m', '\x1b[38;5;234m', '\x1b[38;5;182m'],
28
+ basic: ['\x1b[45m', '\x1b[30m', '\x1b[35m'],
29
+ };
30
+ // The family accents the board gives each vendor (adapters/config.js colour, design/app.css
31
+ // --seat-*), per tier, so a vendor reads the same colour in the terminal as on its seat.
32
+ const ACCENTS = {
33
+ rust: ['\x1b[38;2;223;107;85m', '\x1b[38;5;173m', '\x1b[31m'],
34
+ olive: ['\x1b[38;2;156;188;106m', '\x1b[38;5;149m', '\x1b[32m'],
35
+ teal: ['\x1b[38;2;111;179;169m', '\x1b[38;5;73m', '\x1b[36m'],
36
+ steel: ['\x1b[38;2;134;169;196m', '\x1b[38;5;110m', '\x1b[34m'],
37
+ };
38
+ const TIERS = ['true', '256', 'basic'];
39
+
40
+ // Nothing is decorated for a pipe, CI or a dumb terminal; NO_COLOR keeps the text and drops
41
+ // the codes.
42
+ const decorated = (isTTY, env) => isTTY && !env.CI && env.TERM !== 'dumb';
43
+ const painter = (env) => (s, code) => env.NO_COLOR ? s : `${code}${s}${RESET}`;
44
+
45
+ // A vendor's name in its seat colour: paintVendor(ADAPTERS.codex) -> coloured "Codex".
46
+ export function paintVendor({ label, colour }, { text = label, isTTY = process.stdout.isTTY, env = process.env, platform = process.platform } = {}) {
47
+ if (!decorated(isTTY, env) || !ACCENTS[colour]) return text;
48
+ return painter(env)(text, ACCENTS[colour][TIERS.indexOf(tier(env, platform))]);
26
49
  }
27
50
 
28
51
  function tileRows(colour) {
@@ -39,15 +62,16 @@ function tileRows(colour) {
39
62
  }
40
63
 
41
64
  export function terminalBanner({ isTTY = process.stdout.isTTY, columns = process.stdout.columns ?? 80, env = process.env, platform = process.platform } = {}) {
42
- if (!isTTY || env.CI || env.TERM === 'dumb') return '';
43
- const paint = (s, code) => env.NO_COLOR ? s : `${code}${s}${RESET}`;
44
- const [tile, ink, orchid] = palette(env, platform);
65
+ if (!decorated(isTTY, env)) return '';
66
+ const paint = painter(env);
67
+ const [tile, ink, orchid] = PALETTE[tier(env, platform)];
45
68
  const version = `v${VERSION}`;
46
- const name = paint('ROUNDTABLE', '\x1b[1m');
47
- const compact = `${name}${columns >= 12 + version.length ? ` ${paint(version, '\x1b[2m')}` : ''}`;
69
+ const name = paint('ROUNDTABLE', BOLD);
70
+ const compact = `${name}${columns >= 12 + version.length ? ` ${paint(version, DIM)}` : ''}`;
48
71
  // 2 indent + 16 tile + 3 gap + the tagline, the widest line beside the tile
49
72
  if (!unicode(env, platform) || columns < 21 + TAGLINE.length) return `\n${compact}\n\n`;
50
- const beside = { 2: `${name} ${paint('▌', orchid)}`, 3: paint(version, '\x1b[2m'), 5: paint(TAGLINE, '\x1b[2m') };
73
+ // the block cursor blinks like the board header's, where the terminal allows blinking text
74
+ const beside = { 2: `${name} ${paint('▌', orchid + BLINK)}`, 3: paint(version, DIM), 5: paint(TAGLINE, DIM) };
51
75
  const lines = tileRows((row) => paint(row, tile + ink)).map((row, y) => ` ${row}${beside[y] ? ` ${beside[y]}` : ''}`);
52
76
  return `\n${lines.join('\n')}\n\n`;
53
77
  }