@nbtca/prompt 1.4.2 → 1.5.1

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.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -0,0 +1,37 @@
1
+ const DAY_MS = 86_400_000;
2
+ const LOCAL_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
3
+ export function parseLocalDate(value) {
4
+ const match = LOCAL_DATE_PATTERN.exec(value);
5
+ if (!match)
6
+ throw new RangeError(`Invalid local date: ${value}`);
7
+ const year = Number(match[1]);
8
+ const month = Number(match[2]);
9
+ const day = Number(match[3]);
10
+ const date = new Date(0);
11
+ date.setHours(0, 0, 0, 0);
12
+ date.setFullYear(year, month - 1, day);
13
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
14
+ throw new RangeError(`Invalid local date: ${value}`);
15
+ }
16
+ return date;
17
+ }
18
+ export function parseLocalMonday(value) {
19
+ const date = parseLocalDate(value);
20
+ if (date.getDay() !== 1)
21
+ throw new RangeError(`Local date is not a Monday: ${value}`);
22
+ return date;
23
+ }
24
+ export function addLocalDays(date, days) {
25
+ const result = new Date(date.getTime());
26
+ result.setDate(result.getDate() + days);
27
+ return result;
28
+ }
29
+ function localDayIndex(date) {
30
+ const index = new Date(0);
31
+ index.setUTCHours(0, 0, 0, 0);
32
+ index.setUTCFullYear(date.getFullYear(), date.getMonth(), date.getDate());
33
+ return index.getTime() / DAY_MS;
34
+ }
35
+ export function localDayDifference(start, end) {
36
+ return localDayIndex(end) - localDayIndex(start);
37
+ }
@@ -8,6 +8,7 @@ export const ansi = {
8
8
  enterAlt: `${CSI}?1049h`,
9
9
  leaveAlt: `${CSI}?1049l`,
10
10
  home: `${CSI}H`,
11
+ cursorToRow: (row) => `${CSI}${row};1H`,
11
12
  clearAll: `${CSI}2J`,
12
13
  };
13
14
  let registered = false;
@@ -1,5 +1,8 @@
1
1
  import { useUnicodeIcons } from './icons.js';
2
2
  import { resolveColorMode } from '../config/preferences.js';
3
+ function isTty(value) {
4
+ return value === true;
5
+ }
3
6
  export function deriveReducedMotion(o) {
4
7
  const env = o.env ?? process.env;
5
8
  if (!o.isTTY)
@@ -8,7 +11,7 @@ export function deriveReducedMotion(o) {
8
11
  return true;
9
12
  if (env['CI'])
10
13
  return true;
11
- if ((env['TERM'] || '').toLowerCase() === 'dumb')
14
+ if ((env['TERM'] ?? '').toLowerCase() === 'dumb')
12
15
  return true;
13
16
  if (!o.color)
14
17
  return true;
@@ -24,13 +27,13 @@ function detectColor() {
24
27
  return false;
25
28
  if (mode === 'on')
26
29
  return true;
27
- return !!process.stdout.isTTY;
30
+ return isTty(process.stdout.isTTY);
28
31
  }
29
32
  let cached = null;
30
33
  export function getCapabilities() {
31
34
  if (cached)
32
35
  return cached;
33
- const isTTY = !!process.stdout.isTTY && !!process.stdin.isTTY;
36
+ const isTTY = isTty(process.stdout.isTTY) && isTty(process.stdin.isTTY);
34
37
  const unicode = useUnicodeIcons();
35
38
  const color = detectColor();
36
39
  const reducedMotion = deriveReducedMotion({ isTTY, color, unicode });
@@ -19,12 +19,13 @@ export function parseConfirmData(data) {
19
19
  export function renderConfirm(opts) {
20
20
  const cursor = glyph.cursor();
21
21
  const gap = ' '.repeat(cursor.length);
22
- const yes = opts.value ? `${type.active(cursor)} ${type.active('Yes')}` : `${gap} ${type.body('Yes')}`;
23
- const no = opts.value ? `${gap} ${type.body('No')}` : `${type.active(cursor)} ${type.active('No')}`;
24
- return [
25
- space.indent + type.label(opts.message),
26
- `${space.indent}${yes} ${no}`,
27
- ].join('\n');
22
+ const yes = opts.value
23
+ ? `${type.active(cursor)} ${type.active('Yes')}`
24
+ : `${gap} ${type.body('Yes')}`;
25
+ const no = opts.value
26
+ ? `${gap} ${type.body('No')}`
27
+ : `${type.active(cursor)} ${type.active('No')}`;
28
+ return [space.indent + type.label(opts.message), `${space.indent}${yes} ${no}`].join('\n');
28
29
  }
29
30
  export function runConfirm(config) {
30
31
  return new Promise((resolve) => {
@@ -47,12 +48,12 @@ export function runConfirm(config) {
47
48
  finish(value);
48
49
  return;
49
50
  }
50
- if (ev === 'yes' && value !== true) {
51
+ if (ev === 'yes' && !value) {
51
52
  value = true;
52
53
  paint();
53
54
  return;
54
55
  }
55
- if (ev === 'no' && value !== false) {
56
+ if (ev === 'no' && value) {
56
57
  value = false;
57
58
  paint();
58
59
  return;
@@ -1,31 +1,63 @@
1
1
  import { glyph, type, space } from '../theme.js';
2
2
  import { visualWidth, padEndV, wrapAnsiToVisualWidth } from '../text.js';
3
- import { ansi, ensureCursorRestored } from '../canvas.js';
4
3
  import { createPainter } from './painter.js';
4
+ import { startRawInput } from './input-session.js';
5
5
  import { t } from '../../i18n/index.js';
6
6
  export function parseKey(data) {
7
7
  const s = data.toString();
8
8
  switch (s) {
9
- case '\x1b[A': return 'up';
10
- case '\x1b[B': return 'down';
11
- case '\x1b[H': return 'home';
12
- case '\x1b[F': return 'end';
9
+ case '\x1b[A':
10
+ case 'k':
11
+ return 'up';
12
+ case '\x1b[B':
13
+ case 'j':
14
+ return 'down';
15
+ case '\x1b[5~':
16
+ return 'pageUp';
17
+ case '\x1b[6~':
18
+ return 'pageDown';
19
+ case '\x1b[H':
20
+ case '\x1b[1~':
21
+ case '\x1bOH':
22
+ case 'g':
23
+ return 'home';
24
+ case '\x1b[F':
25
+ case '\x1b[4~':
26
+ case '\x1bOF':
27
+ case 'G':
28
+ return 'end';
13
29
  case '\r':
14
- case '\n': return 'enter';
30
+ case '\n':
31
+ case 'l':
32
+ return 'enter';
15
33
  case '\x03':
16
- case '\x1b': return 'cancel';
17
- default: return 'none';
34
+ case '\x1b':
35
+ case 'q':
36
+ return 'cancel';
37
+ default:
38
+ return 'none';
18
39
  }
19
40
  }
20
- export function nextIndex(current, key, len) {
41
+ export function nextIndex(current, key, len, pageSize = 5) {
21
42
  if (len <= 0)
22
43
  return 0;
23
44
  switch (key) {
24
- case 'up': return (current - 1 + len) % len;
25
- case 'down': return (current + 1) % len;
26
- case 'home': return 0;
27
- case 'end': return len - 1;
28
- default: return current;
45
+ case 'up':
46
+ return (current - 1 + len) % len;
47
+ case 'down':
48
+ return (current + 1) % len;
49
+ case 'pageUp':
50
+ return Math.max(0, current - Math.max(1, pageSize));
51
+ case 'pageDown':
52
+ return Math.min(len - 1, current + Math.max(1, pageSize));
53
+ case 'home':
54
+ return 0;
55
+ case 'end':
56
+ return len - 1;
57
+ case 'enter':
58
+ case 'cancel':
59
+ case 'none':
60
+ return current;
29
61
  }
30
62
  }
31
63
  function normalizedWidth(cols) {
@@ -51,8 +83,7 @@ export function renderMenuOption(option, selected, labelWidth = visualWidth(opti
51
83
  const padded = padEndV(option.label, paddedWidth);
52
84
  const label = selected ? type.active(padded) : type.body(padded);
53
85
  const hint = option.hint ? ` ${type.hint(option.hint)}` : '';
54
- return wrapAnsiToVisualWidth(`${label}${hint}`, contentWidth)
55
- .map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
86
+ return wrapAnsiToVisualWidth(`${label}${hint}`, contentWidth).map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
56
87
  }
57
88
  export function renderMenu(state, cols = Number.POSITIVE_INFINITY) {
58
89
  const labelWidth = state.options.reduce((width, option) => Math.max(width, visualWidth(option.label)), 0);
@@ -72,38 +103,32 @@ export function menuFooter() {
72
103
  const m = t().menu;
73
104
  return `${glyph.updown()} ${m.hintMove} ${glyph.enter()} ${m.hintOpen} q ${m.hintQuit}`;
74
105
  }
75
- // Note: runMenu relies on ambient vim-key translation (j/k/l/g/G/q) being ACTIVE.
76
- // Callers must not invoke it with setVimKeysActive(false) still in effect.
77
106
  export function runMenu(config) {
78
107
  return new Promise((resolve) => {
79
- const stdin = process.stdin;
80
- if (!stdin.isTTY || !process.stdout.isTTY) {
81
- resolve(null);
82
- return;
83
- }
84
108
  let index = config.initialIndex ?? 0;
109
+ let finished = false;
85
110
  const paint = createPainter(() => renderMenu({
86
111
  title: config.title,
87
112
  options: config.options,
88
113
  selectedIndex: index,
89
- footer: config.footer,
114
+ ...(config.footer === undefined ? {} : { footer: config.footer }),
90
115
  }));
91
- const cleanup = () => {
92
- stdin.removeListener('data', onData);
93
- if (stdin.isTTY)
94
- stdin.setRawMode(false);
95
- process.stdout.write('\n' + ansi.showCursor);
116
+ const finish = (result) => {
117
+ if (finished)
118
+ return;
119
+ finished = true;
120
+ handle?.stop();
121
+ process.stdout.write('\n');
122
+ resolve(result);
96
123
  };
97
124
  const onData = (data) => {
98
125
  const key = parseKey(data);
99
126
  if (key === 'cancel') {
100
- cleanup();
101
- resolve(null);
127
+ finish(null);
102
128
  return;
103
129
  }
104
130
  if (key === 'enter') {
105
- cleanup();
106
- resolve(config.options[index]?.value ?? null);
131
+ finish(config.options[index]?.value ?? null);
107
132
  return;
108
133
  }
109
134
  const next = nextIndex(index, key, config.options.length);
@@ -112,11 +137,12 @@ export function runMenu(config) {
112
137
  paint();
113
138
  }
114
139
  };
115
- ensureCursorRestored();
116
- stdin.setRawMode(true);
117
- stdin.resume();
118
- process.stdout.write(ansi.hideCursor);
140
+ const handle = startRawInput(onData);
141
+ if (!handle) {
142
+ finished = true;
143
+ resolve(null);
144
+ return;
145
+ }
119
146
  paint();
120
- stdin.on('data', onData);
121
147
  });
122
148
  }
@@ -10,7 +10,15 @@ export function renderMessage(kind, msg) {
10
10
  const m = MARKERS[kind];
11
11
  return `${space.indent}${m.color(m.icon())} ${msg}`;
12
12
  }
13
- export function success(msg) { console.log(renderMessage('success', msg)); }
14
- export function error(msg) { console.log(renderMessage('error', msg)); }
15
- export function warning(msg) { console.log(renderMessage('warn', msg)); }
16
- export function info(msg) { console.log(renderMessage('info', msg)); }
13
+ export function success(msg) {
14
+ console.log(renderMessage('success', msg));
15
+ }
16
+ export function error(msg) {
17
+ console.log(renderMessage('error', msg));
18
+ }
19
+ export function warning(msg) {
20
+ console.log(renderMessage('warn', msg));
21
+ }
22
+ export function info(msg) {
23
+ console.log(renderMessage('info', msg));
24
+ }
@@ -11,7 +11,9 @@ export function frameRows(frame, cols) {
11
11
  * Returns a paint() closure that redraws a fixed-line-count frame in place,
12
12
  * erasing the previous frame first. `write` is injectable for tests.
13
13
  */
14
- export function createPainter(frame, write = (s) => { process.stdout.write(s); }) {
14
+ export function createPainter(frame, write = (s) => {
15
+ process.stdout.write(s);
16
+ }) {
15
17
  let painted = 0;
16
18
  return () => {
17
19
  const f = frame();
@@ -2,23 +2,44 @@ import { getCapabilities } from '../capabilities.js';
2
2
  import { ansi, ensureCursorRestored } from '../canvas.js';
3
3
  import { renderMessage } from './messages.js';
4
4
  import { pickIcon } from '../icons.js';
5
- import { c, space } from '../theme.js';
5
+ import { c, space, type } from '../theme.js';
6
+ import { visualWidth, wrapAnsiWithIndent } from '../text.js';
6
7
  const FRAMES_UNICODE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
8
  const FRAMES_ASCII = ['|', '/', '-', '\\'];
9
+ export const SPINNER_FRAME_MS = 80;
8
10
  export function renderSpinnerFrame(frame, msg) {
9
11
  return `${space.indent}${c.accent(frame)} ${msg}`;
10
12
  }
13
+ export function spinnerFrame(at = Date.now()) {
14
+ const frames = pickIcon('u', 'a') === 'u' ? FRAMES_UNICODE : FRAMES_ASCII;
15
+ const index = getCapabilities().reducedMotion
16
+ ? 0
17
+ : Math.floor(at / SPINNER_FRAME_MS) % frames.length;
18
+ return frames[index] ?? '|';
19
+ }
20
+ export function loadingLines(label, cols = Number.POSITIVE_INFINITY, at = Date.now()) {
21
+ const message = type.hint(label);
22
+ const spun = `${space.indent}${c.accent(spinnerFrame(at))} ${message}`;
23
+ if (visualWidth(spun) <= cols)
24
+ return [spun];
25
+ return wrapAnsiWithIndent(message, cols, space.indent);
26
+ }
11
27
  export function startSpinner(msg = '', opts = {}) {
12
- const write = opts.write ?? ((s) => { process.stdout.write(s); });
28
+ const write = opts.write ??
29
+ ((s) => {
30
+ process.stdout.write(s);
31
+ });
13
32
  const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
14
33
  const frames = pickIcon('u', 'a') === 'u' ? FRAMES_UNICODE : FRAMES_ASCII;
15
34
  let current = msg;
16
35
  let timer = null;
17
36
  let i = 0;
18
- const clearLine = () => write(ansi.cursorToCol0 + ansi.eraseDown);
37
+ const clearLine = () => {
38
+ write(ansi.cursorToCol0 + ansi.eraseDown);
39
+ };
19
40
  const paint = () => {
20
41
  clearLine();
21
- write(renderSpinnerFrame(frames[i % frames.length], current));
42
+ write(renderSpinnerFrame(frames.at(i % frames.length) ?? '|', current));
22
43
  i++;
23
44
  };
24
45
  if (!reduced) {
@@ -40,8 +61,14 @@ export function startSpinner(msg = '', opts = {}) {
40
61
  write(line + '\n');
41
62
  };
42
63
  return {
43
- message: (m) => { current = m; },
44
- stop: (m) => finish(m ? renderMessage('success', m) : null),
45
- error: (m) => finish(m ? renderMessage('error', m) : null),
64
+ message: (m) => {
65
+ current = m;
66
+ },
67
+ stop: (m) => {
68
+ finish(m ? renderMessage('success', m) : null);
69
+ },
70
+ error: (m) => {
71
+ finish(m ? renderMessage('error', m) : null);
72
+ },
46
73
  };
47
74
  }
@@ -2,28 +2,34 @@ import { glyph, type, space } from '../theme.js';
2
2
  import { startRawInput } from './input-session.js';
3
3
  import { setVimKeysActive } from '../vim-keys.js';
4
4
  import { createPainter } from './painter.js';
5
- import { visualWidth, wrapAnsiToVisualWidth } from '../text.js';
5
+ import { visualWidth, truncateStart, wrapAnsiToVisualWidth } from '../text.js';
6
+ import { pickIcon } from '../icons.js';
7
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
8
+ function graphemes(value) {
9
+ return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment);
10
+ }
6
11
  export function parseInputData(data) {
7
12
  const s = data.toString();
8
13
  if (s === '\r' || s === '\n')
9
14
  return { type: 'enter' };
10
15
  if (s === '\x03')
11
- return { type: 'cancel' }; // ctrl-c
16
+ return { type: 'cancel' };
12
17
  if (s === '\x1b')
13
- return { type: 'cancel' }; // bare esc
18
+ return { type: 'cancel' };
14
19
  if (s === '\x7f' || s === '\b')
15
20
  return { type: 'backspace' };
16
21
  if (s.startsWith('\x1b'))
17
- return { type: 'none' }; // escape sequence (arrows, etc.)
18
- // printable run: drop control chars, keep the rest (supports paste / batched keys)
19
- const text = [...s].filter((ch) => ch >= ' ' && ch !== '\x7f').join('');
22
+ return { type: 'none' };
23
+ const text = graphemes(s)
24
+ .filter((ch) => ch >= ' ' && ch !== '\x7f')
25
+ .join('');
20
26
  return text.length > 0 ? { type: 'char', ch: text } : { type: 'none' };
21
27
  }
22
28
  export function applyInputEvent(value, ev) {
23
29
  if (ev.type === 'char')
24
30
  return value + ev.ch;
25
31
  if (ev.type === 'backspace')
26
- return [...value].slice(0, -1).join('');
32
+ return graphemes(value).slice(0, -1).join('');
27
33
  return value;
28
34
  }
29
35
  export function renderInput(opts) {
@@ -32,21 +38,21 @@ export function renderInput(opts) {
32
38
  : Number.POSITIVE_INFINITY;
33
39
  const indent = visualWidth(space.indent) < width ? space.indent : '';
34
40
  const messageWidth = Math.max(1, width - visualWidth(indent));
35
- const messageLines = wrapAnsiToVisualWidth(type.label(opts.message), messageWidth)
36
- .map((line) => `${indent}${line}`);
41
+ const messageLines = wrapAnsiToVisualWidth(type.label(opts.message), messageWidth).map((line) => `${indent}${line}`);
37
42
  const visibleValue = opts.secret
38
- ? (opts.mask ?? '*').repeat([...opts.value].length)
43
+ ? (opts.mask ?? '*').repeat(graphemes(opts.value).length)
39
44
  : opts.value;
40
- const shown = opts.value.length > 0
41
- ? type.body(visibleValue)
42
- : type.hint(opts.placeholder ?? '');
43
45
  const cursor = type.active(glyph.cursor());
44
46
  const prefixes = [`${space.indent}${cursor} `, `${cursor} `, cursor, ''];
45
47
  const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? '';
46
48
  const continuation = ' '.repeat(visualWidth(prefix));
47
49
  const inputWidth = Math.max(1, width - visualWidth(prefix));
48
- const inputLines = wrapAnsiToVisualWidth(shown, inputWidth)
49
- .map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
50
+ const shown = opts.value.length > 0
51
+ ? type.body(truncateStart(visibleValue, inputWidth, pickIcon('…', '<')))
52
+ : type.hint(opts.placeholder ?? '');
53
+ const inputLines = opts.value.length > 0
54
+ ? [`${prefix}${shown}`]
55
+ : wrapAnsiToVisualWidth(shown, inputWidth).map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
50
56
  return [...messageLines, ...inputLines].join('\n');
51
57
  }
52
58
  export function runTextInput(config) {
@@ -55,9 +61,9 @@ export function runTextInput(config) {
55
61
  const frame = () => renderInput({
56
62
  message: config.message,
57
63
  value,
58
- placeholder: config.placeholder,
59
- secret: config.secret,
60
- mask: config.mask,
64
+ ...(config.placeholder === undefined ? {} : { placeholder: config.placeholder }),
65
+ ...(config.secret === undefined ? {} : { secret: config.secret }),
66
+ ...(config.mask === undefined ? {} : { mask: config.mask }),
61
67
  });
62
68
  const paint = createPainter(frame);
63
69
  const onData = (data) => {
@@ -1,6 +1,6 @@
1
1
  import { resolveIconMode } from '../config/preferences.js';
2
2
  function localeSupportsUnicode() {
3
- const locale = `${process.env['LC_ALL'] || ''} ${process.env['LANG'] || ''}`.toLowerCase();
3
+ const locale = `${process.env['LC_ALL'] ?? ''} ${process.env['LANG'] ?? ''}`.toLowerCase();
4
4
  return locale.includes('utf-8') || locale.includes('utf8');
5
5
  }
6
6
  let cachedUseUnicode = null;
@@ -16,7 +16,7 @@ export function useUnicodeIcons() {
16
16
  cachedUseUnicode = true;
17
17
  return true;
18
18
  }
19
- const term = (process.env['TERM'] || '').toLowerCase();
19
+ const term = (process.env['TERM'] ?? '').toLowerCase();
20
20
  if (!process.stdout.isTTY || term === 'dumb') {
21
21
  cachedUseUnicode = false;
22
22
  return false;
package/dist/core/logo.js CHANGED
@@ -6,6 +6,7 @@ import { useUnicodeIcons } from './icons.js';
6
6
  import { APP_INFO } from '../config/data.js';
7
7
  import { typeReveal, materializeBraille } from './motion.js';
8
8
  import { brandGradient as brand } from './theme.js';
9
+ import { visualWidth } from './text.js';
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const TAGLINE = 'To be at the intersection of technology and liberal arts.';
11
12
  function readArt(file) {
@@ -22,8 +23,8 @@ const LOGO_TIERS = [
22
23
  { file: 'ca-dotmatrix-small.txt', minCols: 0, minRows: 0 },
23
24
  ];
24
25
  function dotmatrixFile() {
25
- const cols = process.stdout.columns ?? 0;
26
- const rows = process.stdout.rows ?? 0;
26
+ const cols = process.stdout.columns;
27
+ const rows = process.stdout.rows;
27
28
  const tier = LOGO_TIERS.find((t) => cols >= t.minCols && rows >= t.minRows);
28
29
  return tier?.file ?? 'ca-dotmatrix-small.txt';
29
30
  }
@@ -33,12 +34,21 @@ function paint(text, color) {
33
34
  const fn = brand;
34
35
  return typeof fn.multiline === 'function'
35
36
  ? fn.multiline(text)
36
- : text.split('\n').map((line) => brand(line)).join('\n');
37
+ : text
38
+ .split('\n')
39
+ .map((line) => brand(line))
40
+ .join('\n');
37
41
  }
38
42
  function loadArt() {
39
43
  const art = useUnicodeIcons() ? readArt(dotmatrixFile()) : readArt('ascii-logo.txt');
40
44
  return art ?? 'NBTCA';
41
45
  }
46
+ export function startupFitsTerminal(rows, cols, art) {
47
+ const lines = art.split('\n');
48
+ const fitsRows = rows === undefined || rows >= lines.length + 5;
49
+ const fitsCols = cols === undefined || lines.every((line) => visualWidth(line) <= cols);
50
+ return fitsRows && fitsCols;
51
+ }
42
52
  export function buildLogoLines() {
43
53
  const color = !process.env['NO_COLOR'];
44
54
  const paintedArt = paint(loadArt(), color).split('\n');
@@ -54,8 +64,16 @@ export function buildLogoLines() {
54
64
  export async function runStartup() {
55
65
  if (!process.stdout.isTTY)
56
66
  return;
67
+ const art = loadArt();
68
+ if (!startupFitsTerminal(process.stdout.rows, process.stdout.columns, art))
69
+ return;
57
70
  const color = !process.env['NO_COLOR'];
58
71
  process.stdout.write('\n');
59
- await materializeBraille(loadArt(), (s) => paint(s, color));
60
- await typeReveal(['', color ? brand(TAGLINE) : TAGLINE, chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), '']);
72
+ await materializeBraille(art, (s) => paint(s, color));
73
+ await typeReveal([
74
+ '',
75
+ color ? brand(TAGLINE) : TAGLINE,
76
+ chalk.dim(`@nbtca/prompt v${APP_INFO.version}`),
77
+ '',
78
+ ]);
61
79
  }
@@ -1,10 +1,14 @@
1
1
  import { getCapabilities } from './capabilities.js';
2
2
  import { ansi } from './canvas.js';
3
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
4
  export function sleep(ms) {
4
5
  return new Promise((resolve) => setTimeout(resolve, ms));
5
6
  }
6
7
  export async function typeReveal(lines, opts = {}) {
7
- const write = opts.write ?? ((s) => { process.stdout.write(s); });
8
+ const write = opts.write ??
9
+ ((s) => {
10
+ process.stdout.write(s);
11
+ });
8
12
  const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
9
13
  if (reduced) {
10
14
  write(lines.join('\n') + '\n');
@@ -21,31 +25,29 @@ function brailleMask(ch) {
21
25
  const code = ch.codePointAt(0) ?? 0;
22
26
  return code >= BRAILLE_BASE && code <= BRAILLE_BASE + 0xff ? code - BRAILLE_BASE : -1;
23
27
  }
24
- /**
25
- * Reveals a block of braille dot-matrix art by assembling it dot-by-dot in
26
- * scattered order, rather than popping it in line by line -- a reveal that
27
- * matches what the art actually is (an addressable grid of dots) instead of
28
- * reusing the plain-text typewriter effect built for prose. Non-braille
29
- * characters (spaces, stray ASCII) pass through unchanged on every frame.
30
- */
31
28
  export async function materializeBraille(art, paint, opts = {}) {
32
- const write = opts.write ?? ((s) => { process.stdout.write(s); });
29
+ const write = opts.write ??
30
+ ((s) => {
31
+ process.stdout.write(s);
32
+ });
33
33
  const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
34
34
  const lines = art.split('\n');
35
- const charGrid = lines.map((line) => [...line]);
35
+ const charGrid = lines.map((line) => Array.from(GRAPHEME_SEGMENTER.segment(line), ({ segment }) => segment));
36
36
  const maskGrid = charGrid.map((row) => row.map(brailleMask));
37
37
  if (reduced || !maskGrid.some((row) => row.some((m) => m > 0))) {
38
38
  write(paint(art) + '\n');
39
39
  return;
40
40
  }
41
41
  const dots = [];
42
- maskGrid.forEach((row, r) => row.forEach((mask, c) => {
43
- if (mask <= 0)
44
- return;
45
- for (let bit = 0; bit < 8; bit++)
46
- if (mask & (1 << bit))
47
- dots.push([r, c, bit]);
48
- }));
42
+ maskGrid.forEach((row, r) => {
43
+ row.forEach((mask, c) => {
44
+ if (mask <= 0)
45
+ return;
46
+ for (let bit = 0; bit < 8; bit++)
47
+ if (mask & (1 << bit))
48
+ dots.push([r, c, bit]);
49
+ });
50
+ });
49
51
  const rand = opts.random ?? Math.random;
50
52
  for (let i = dots.length - 1; i > 0; i--) {
51
53
  const j = Math.floor(rand() * (i + 1));
@@ -56,12 +58,16 @@ export async function materializeBraille(art, paint, opts = {}) {
56
58
  }
57
59
  }
58
60
  const acc = maskGrid.map((row) => row.map(() => 0));
59
- const renderFrame = () => charGrid.map((row, r) => row.map((original, c) => {
61
+ const renderFrame = () => charGrid
62
+ .map((row, r) => row
63
+ .map((original, c) => {
60
64
  const mask = maskGrid[r]?.[c] ?? -1;
61
65
  if (mask <= 0)
62
66
  return original;
63
67
  return String.fromCodePoint(BRAILLE_BASE + (acc[r]?.[c] ?? 0));
64
- }).join('')).join('\n');
68
+ })
69
+ .join(''))
70
+ .join('\n');
65
71
  const frameCount = Math.max(1, opts.frames ?? 12);
66
72
  const frameMs = opts.frameMs ?? 35;
67
73
  let shown = 0;