@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
package/dist/core/text.js CHANGED
@@ -1,95 +1,182 @@
1
- /** Codepoints that occupy zero terminal columns: combining modifiers that
2
- * merge into the glyph immediately before them, rather than rendering as
3
- * their own character (zero-width joiner, variation selectors). Emoji
4
- * sequences like a ZWJ family emoji or "❤️" (heart + VS-16) render as one
5
- * glyph — counting the modifier itself would overcount by a full column. */
1
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
2
+ const MARK_RE = /^\p{Mark}$/u;
3
+ const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u;
4
+ const REGIONAL_INDICATOR_RE = /\p{Regional_Indicator}/u;
5
+ function graphemes(value) {
6
+ return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment);
7
+ }
6
8
  function isZeroWidth(cp) {
7
- return cp === 0x200D // zero-width joiner
8
- || cp === 0xFE0E // variation selector-15 (text presentation)
9
- || cp === 0xFE0F; // variation selector-16 (emoji presentation)
9
+ return (cp === 0x200d || // zero-width joiner
10
+ cp === 0xfe0e || // variation selector-15 (text presentation)
11
+ cp === 0xfe0f); // variation selector-16 (emoji presentation)
10
12
  }
11
- /** Width of a single Unicode character: 2 for CJK/fullwidth/emoji, 1 otherwise. */
12
- function charWidth(ch) {
13
+ function codePointWidth(ch) {
13
14
  const cp = ch.codePointAt(0) ?? 0;
14
- return ((cp >= 0x1100 && cp <= 0x115F) ||
15
- (cp >= 0x2E80 && cp <= 0x303F) ||
16
- (cp >= 0x3040 && cp <= 0x33FF) ||
17
- (cp >= 0x3400 && cp <= 0x4DBF) ||
18
- (cp >= 0x4E00 && cp <= 0x9FFF) ||
19
- (cp >= 0xAC00 && cp <= 0xD7AF) ||
20
- (cp >= 0xF900 && cp <= 0xFAFF) ||
21
- (cp >= 0xFE30 && cp <= 0xFE4F) ||
22
- (cp >= 0xFF00 && cp <= 0xFF60) ||
23
- (cp >= 0xFFE0 && cp <= 0xFFE6) ||
24
- (cp >= 0x20000 && cp <= 0x2A6DF) ||
25
- (cp >= 0x2A700 && cp <= 0x2CEAF) ||
26
- (cp >= 0x2CEB0 && cp <= 0x2EBEF) ||
27
- (cp >= 0x30000 && cp <= 0x323AF) ||
28
- // Emoji blocks render as double-width terminal glyphs.
29
- (cp >= 0x1F300 && cp <= 0x1FAFF)) ? 2 : 1;
30
- }
31
- /** Strip ANSI escape sequences from a string. */
32
- // eslint-disable-next-line no-control-regex
33
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
15
+ if (isZeroWidth(cp) || MARK_RE.test(ch) || cp < 0x20 || (cp >= 0x7f && cp <= 0x9f))
16
+ return 0;
17
+ return (cp >= 0x1100 && cp <= 0x115f) ||
18
+ (cp >= 0x2e80 && cp <= 0x303f) ||
19
+ (cp >= 0x3040 && cp <= 0x33ff) ||
20
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
21
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
22
+ (cp >= 0xac00 && cp <= 0xd7af) ||
23
+ (cp >= 0xf900 && cp <= 0xfaff) ||
24
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
25
+ (cp >= 0xff00 && cp <= 0xff60) ||
26
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
27
+ (cp >= 0x20000 && cp <= 0x2a6df) ||
28
+ (cp >= 0x2a700 && cp <= 0x2ceaf) ||
29
+ (cp >= 0x2ceb0 && cp <= 0x2ebef) ||
30
+ (cp >= 0x30000 && cp <= 0x323af) ||
31
+ (cp >= 0x1f300 && cp <= 0x1faff)
32
+ ? 2
33
+ : 1;
34
+ }
35
+ function graphemeWidth(grapheme) {
36
+ if (grapheme.includes('\ufe0f') ||
37
+ grapheme.includes('\u20e3') ||
38
+ EMOJI_PRESENTATION_RE.test(grapheme) ||
39
+ REGIONAL_INDICATOR_RE.test(grapheme)) {
40
+ return 2;
41
+ }
42
+ let width = 0;
43
+ for (const ch of grapheme)
44
+ width = Math.max(width, codePointWidth(ch));
45
+ return width;
46
+ }
47
+ const TERMINAL_ESCAPE_RE = /(?:\u001B\]|\u009D)[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)|(?:\u001B[P_X^]|[\u0090\u0098\u009E\u009F])[\s\S]*?(?:\u001B\\|\u009C|$)|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[ -/]*[@-~]/g;
48
+ const SGR_RE = /^(?:\u001B\[|\u009B)[0-9;]*m$/;
49
+ const OSC8_RE = /^(?:\u001B\]|\u009D)8;[^;]*;([\s\S]*?)(?:\u0007|\u001B\\|\u009C)$/;
50
+ const OSC8_CLOSE = '\u001B]8;;\u0007';
34
51
  export function stripAnsi(str) {
35
- return str.replace(ANSI_RE, '');
52
+ return str.replace(TERMINAL_ESCAPE_RE, '');
53
+ }
54
+ const CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
55
+ export function sanitizeTerminalText(str) {
56
+ return str.replace(/\r\n?/g, '\n').replace(TERMINAL_ESCAPE_RE, '').replace(CONTROL_RE, '');
57
+ }
58
+ export function sanitizeTerminalLine(str) {
59
+ return sanitizeTerminalText(str).replace(/\s+/gu, ' ').trim();
36
60
  }
37
- /** Total visual width of a string (CJK/emoji count as 2, zero-width
38
- * modifiers count as 0, ANSI codes ignored). */
39
61
  export function visualWidth(str) {
40
62
  const plain = stripAnsi(str);
41
63
  let w = 0;
42
- for (const ch of plain) {
43
- const cp = ch.codePointAt(0) ?? 0;
44
- if (isZeroWidth(cp))
45
- continue;
46
- w += charWidth(ch);
47
- }
64
+ for (const grapheme of graphemes(plain))
65
+ w += graphemeWidth(grapheme);
48
66
  return w;
49
67
  }
50
- /** Pad string to target visual width with trailing spaces. */
51
68
  export function padEndV(str, width) {
52
69
  const pad = width - visualWidth(str);
53
70
  return pad > 0 ? str + ' '.repeat(pad) : str;
54
71
  }
55
- /** Truncate to visual width limit, appending '...' if cut. */
56
72
  export function truncate(str, maxWidth) {
57
73
  if (visualWidth(str) <= maxWidth)
58
74
  return str;
75
+ if (maxWidth <= 0)
76
+ return '';
77
+ const marker = maxWidth >= 3 ? '...' : '.'.repeat(maxWidth);
78
+ const available = maxWidth - visualWidth(marker);
59
79
  let w = 0;
60
- let i = 0;
61
- for (const ch of str) {
62
- const cp = ch.codePointAt(0) ?? 0;
63
- const cw = isZeroWidth(cp) ? 0 : charWidth(ch);
64
- if (w + cw > maxWidth - 3)
80
+ let value = '';
81
+ for (const grapheme of graphemes(str)) {
82
+ const cw = graphemeWidth(grapheme);
83
+ if (w + cw > available)
65
84
  break;
66
85
  w += cw;
67
- i += ch.length;
86
+ value += grapheme;
87
+ }
88
+ return value + marker;
89
+ }
90
+ export function truncateStart(str, maxWidth, marker = '...') {
91
+ if (visualWidth(str) <= maxWidth)
92
+ return str;
93
+ if (maxWidth <= 0)
94
+ return '';
95
+ let fittedMarker = '';
96
+ let markerWidth = 0;
97
+ for (const segment of graphemes(marker)) {
98
+ const segmentWidth = graphemeWidth(segment);
99
+ if (markerWidth + segmentWidth > maxWidth)
100
+ break;
101
+ fittedMarker += segment;
102
+ markerWidth += segmentWidth;
68
103
  }
69
- return str.slice(0, i) + '...';
104
+ const available = Math.max(0, maxWidth - visualWidth(fittedMarker));
105
+ const segments = graphemes(str);
106
+ let value = '';
107
+ let width = 0;
108
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
109
+ const segment = segments[index];
110
+ if (segment === undefined)
111
+ continue;
112
+ const segmentWidth = graphemeWidth(segment);
113
+ if (width + segmentWidth > available)
114
+ break;
115
+ value = segment + value;
116
+ width += segmentWidth;
117
+ }
118
+ return fittedMarker + value;
119
+ }
120
+ function osc8State(sequence) {
121
+ const match = OSC8_RE.exec(sequence);
122
+ if (!match)
123
+ return undefined;
124
+ return match[1] ? sequence : null;
70
125
  }
71
126
  function tokenizeForWrapping(str) {
72
127
  const tokens = [];
73
- let index = 0;
74
- while (index < str.length) {
75
- const ansi = /^\x1b\[[0-9;]*m/.exec(str.slice(index));
76
- if (ansi) {
77
- tokens.push({ raw: ansi[0], width: 0, whitespace: false, sgr: true });
78
- index += ansi[0].length;
79
- continue;
128
+ const pushText = (value) => {
129
+ for (const grapheme of graphemes(value)) {
130
+ tokens.push({
131
+ raw: grapheme,
132
+ width: graphemeWidth(grapheme),
133
+ whitespace: /\s/u.test(grapheme),
134
+ sgr: false,
135
+ });
80
136
  }
81
- const cp = str.codePointAt(index) ?? 0;
82
- const raw = String.fromCodePoint(cp);
137
+ };
138
+ let index = 0;
139
+ for (const match of str.matchAll(TERMINAL_ESCAPE_RE)) {
140
+ const start = match.index;
141
+ pushText(str.slice(index, start));
142
+ const raw = match[0];
143
+ const osc8 = osc8State(raw);
83
144
  tokens.push({
84
145
  raw,
85
- width: isZeroWidth(cp) ? 0 : charWidth(raw),
86
- whitespace: /\s/u.test(raw),
87
- sgr: false,
146
+ width: 0,
147
+ whitespace: false,
148
+ sgr: SGR_RE.test(raw),
149
+ ...(osc8 === undefined ? {} : { osc8 }),
88
150
  });
89
- index += raw.length;
151
+ index = start + match[0].length;
90
152
  }
153
+ pushText(str.slice(index));
91
154
  return tokens;
92
155
  }
156
+ export function clipAnsiToVisualWidth(str, maxWidth) {
157
+ const limit = Math.max(0, Math.floor(maxWidth));
158
+ if (visualWidth(str) <= limit)
159
+ return str;
160
+ const tokens = tokenizeForWrapping(str);
161
+ const output = [];
162
+ let width = 0;
163
+ let activeOsc8 = null;
164
+ for (const token of tokens) {
165
+ if (token.sgr || token.width === 0) {
166
+ output.push(token.raw);
167
+ if (token.osc8 !== undefined)
168
+ activeOsc8 = token.osc8;
169
+ continue;
170
+ }
171
+ if (width + token.width > limit)
172
+ break;
173
+ output.push(token.raw);
174
+ width += token.width;
175
+ }
176
+ if (activeOsc8)
177
+ output.push(OSC8_CLOSE);
178
+ return output.join('');
179
+ }
93
180
  function advanceSgr(active, tokens, start, end) {
94
181
  let next = active;
95
182
  for (let index = start; index < end; index += 1) {
@@ -106,12 +193,24 @@ function advanceSgr(active, tokens, start, end) {
106
193
  }
107
194
  return next;
108
195
  }
109
- function renderWrappedSegment(tokens, start, end, prefix) {
110
- const body = tokens.slice(start, end).map((token) => token.raw).join('');
111
- const styled = prefix + body;
112
- return prefix || tokens.slice(start, end).some((token) => token.sgr)
113
- ? `${styled}\x1b[0m`
114
- : styled;
196
+ function advanceOsc8(active, tokens, start, end) {
197
+ let next = active;
198
+ for (let index = start; index < end; index += 1) {
199
+ const token = tokens[index];
200
+ if (token?.osc8 !== undefined)
201
+ next = token.osc8;
202
+ }
203
+ return next;
204
+ }
205
+ function renderWrappedSegment(tokens, start, end, activeSgr, activeOsc8) {
206
+ const body = tokens
207
+ .slice(start, end)
208
+ .map((token) => token.raw)
209
+ .join('');
210
+ const styled = `${activeOsc8 ?? ''}${activeSgr}${body}`;
211
+ const withReset = activeSgr || tokens.slice(start, end).some((token) => token.sgr) ? `${styled}\x1b[0m` : styled;
212
+ const osc8AtEnd = advanceOsc8(activeOsc8, tokens, start, end);
213
+ return osc8AtEnd ? `${withReset}${OSC8_CLOSE}` : withReset;
115
214
  }
116
215
  export function wrapAnsiToVisualWidth(str, maxWidth) {
117
216
  const widthLimit = Math.max(1, Math.floor(maxWidth));
@@ -120,6 +219,7 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
120
219
  const tokens = tokenizeForWrapping(str);
121
220
  const lines = [];
122
221
  let activeSgr = '';
222
+ let activeOsc8 = null;
123
223
  let start = 0;
124
224
  while (start < tokens.length) {
125
225
  let width = 0;
@@ -143,7 +243,7 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
143
243
  index += 1;
144
244
  }
145
245
  if (index >= tokens.length) {
146
- lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr));
246
+ lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr, activeOsc8));
147
247
  break;
148
248
  }
149
249
  const overflow = tokens[index];
@@ -160,9 +260,26 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
160
260
  end = Math.max(index, start + 1);
161
261
  next = end;
162
262
  }
163
- lines.push(renderWrappedSegment(tokens, start, end, activeSgr));
263
+ lines.push(renderWrappedSegment(tokens, start, end, activeSgr, activeOsc8));
164
264
  activeSgr = advanceSgr(activeSgr, tokens, start, next);
265
+ activeOsc8 = advanceOsc8(activeOsc8, tokens, start, next);
165
266
  start = next;
166
267
  }
167
268
  return lines.length > 0 ? lines : [''];
168
269
  }
270
+ export function wrapAnsiWithIndent(str, maxWidth, preferredIndent = '') {
271
+ const width = Number.isFinite(maxWidth)
272
+ ? Math.max(1, Math.floor(maxWidth))
273
+ : Number.POSITIVE_INFINITY;
274
+ const indentWidth = visualWidth(preferredIndent);
275
+ const contentWidth = visualWidth(str);
276
+ let indent = indentWidth >= width || (contentWidth > width - indentWidth && contentWidth <= width)
277
+ ? ''
278
+ : preferredIndent;
279
+ let lines = wrapAnsiToVisualWidth(str, Math.max(1, width - visualWidth(indent)));
280
+ if (indent && lines.some((line) => visualWidth(indent + line) > width)) {
281
+ indent = '';
282
+ lines = wrapAnsiToVisualWidth(str, width);
283
+ }
284
+ return lines.map((line) => `${indent}${line}`);
285
+ }
@@ -1,21 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
  import { pickIcon } from './icons.js';
4
- // The one gradient the brand uses anywhere it appears -- the startup logo,
5
- // and (in text form) the persistent header wordmark. Defined once here so
6
- // both stay the same three stops instead of drifting apart.
7
4
  export const brandGradient = gradient([
8
5
  { color: '#124689', pos: 0 },
9
6
  { color: '#0ea5e9', pos: 0.55 },
10
7
  { color: '#06b6d4', pos: 1 },
11
8
  ]);
12
- /** The brand wordmark treatment: bold text painted in `brandGradient`,
13
- * falling back to plain text under NO_COLOR (gradient-string doesn't
14
- * auto-respect it the way chalk's own colors do). Currently used by the
15
- * header's persistent "nbtca" mark (`app/chrome.ts`) -- named here, not
16
- * left as a one-off local helper, so any future chrome element that wants
17
- * "the brand gradient, as a wordmark" has a single place to reuse instead
18
- * of re-deriving the NO_COLOR/bold/gradient combination again. */
19
9
  export function brandMark(s) {
20
10
  if (process.env['NO_COLOR'])
21
11
  return s;
@@ -49,11 +39,6 @@ export const glyph = {
49
39
  dot: () => pickIcon('●', '*'),
50
40
  updown: () => pickIcon('↑↓', 'up/down'),
51
41
  enter: () => pickIcon('⏎', 'enter'),
52
- // Two-level "how full" bar cell (Home's day-progress, Schedule's
53
- // term-progress) — a single source of truth so every such bar in the
54
- // app reads as the same visual language. Not the same vocabulary as
55
- // calendar-heatmap's 5-level intensity scale, which is a deliberately
56
- // finer-grained density visualization, not a binary fill/empty bar.
57
42
  barFilled: () => pickIcon('█', '#'),
58
43
  barEmpty: () => pickIcon('░', '-'),
59
44
  };
@@ -65,19 +50,6 @@ export const type = {
65
50
  label: (s) => chalk.white(s),
66
51
  body: (s) => s,
67
52
  hint: (s) => chalk.dim(s),
68
- /** The one thing on this screen your eye should land on: the app's own
69
- * name, the tab you're on, the row a menu's cursor sits on, the class
70
- * that's happening right now. `heading` marks a section as structure;
71
- * `active` marks a single point as attention — never both on the same
72
- * element, and never more than one or two `active` uses per screen, or
73
- * the signal stops meaning anything. Brand color (#0ea5e9) precisely
74
- * because there is exactly one brand-worthy thing to say on each of
75
- * these screens, and this is where it belongs. */
76
53
  active: (s) => chalk.bold(c.brand(s)),
77
- /** The grid cursor's own visual signal: a solid brand-colored background
78
- * block, deliberately distinct from `active` (bold text on the default
79
- * background) so "this is today" and "this is where your cursor is" never
80
- * share one visual language, even when the cursor lands on today's own
81
- * column. */
82
54
  cursor: (s) => chalk.bgHex('#0ea5e9').black(s),
83
55
  };
@@ -1,15 +1,15 @@
1
1
  import { clearScreen } from './ui.js';
2
2
  import { typeReveal } from './motion.js';
3
3
  import { glyph, type, space } from './theme.js';
4
- import { screenWidth } from './components/screen.js';
5
4
  import { pickIcon } from './icons.js';
6
5
  export function breadcrumb(label) {
7
6
  return `nbtca ${pickIcon('›', '>')} ${label}`;
8
7
  }
9
8
  export function buildScreenHeaderLines(crumb) {
9
+ const width = Math.min(process.stdout.columns || 80, 64);
10
10
  return [
11
11
  space.indent + type.heading(crumb),
12
- space.indent + type.hint(glyph.rule().repeat(screenWidth())),
12
+ space.indent + type.hint(glyph.rule().repeat(width)),
13
13
  '',
14
14
  ];
15
15
  }
package/dist/core/ui.js CHANGED
@@ -1,29 +1,31 @@
1
1
  import { success, error, warning, info } from './components/messages.js';
2
2
  import { startSpinner } from './components/spinner.js';
3
3
  import chalk from 'chalk';
4
- import { pickIcon } from './icons.js';
5
4
  import { t } from '../i18n/index.js';
5
+ import { sanitizeTerminalLine } from './text.js';
6
6
  export { success, error, warning, info };
7
- export function printDivider() {
8
- const terminalWidth = process.stdout.columns || 80;
9
- const dividerChar = pickIcon('─', '-');
10
- console.log(chalk.dim(dividerChar.repeat(Math.min(terminalWidth, 80))));
11
- }
12
7
  export function clearScreen() {
13
8
  if (process.stdout.isTTY) {
14
9
  console.clear();
15
10
  }
16
11
  }
17
- export function printNewLine(count = 1) {
18
- for (let i = 0; i < count; i++) {
19
- console.log();
20
- }
21
- }
22
12
  export function createSpinner(msg) {
23
13
  return startSpinner(msg);
24
14
  }
15
+ function errorMessage(errorValue) {
16
+ if (errorValue instanceof Error)
17
+ return errorValue.message;
18
+ if (typeof errorValue === 'string')
19
+ return errorValue;
20
+ if (typeof errorValue === 'number' ||
21
+ typeof errorValue === 'boolean' ||
22
+ typeof errorValue === 'bigint') {
23
+ return String(errorValue);
24
+ }
25
+ return '';
26
+ }
25
27
  export function handleGracefulExit(err) {
26
- const message = err instanceof Error ? err.message : String(err ?? '');
28
+ const message = sanitizeTerminalLine(errorMessage(err));
27
29
  if (message.includes('SIGINT') || message.includes('User force closed')) {
28
30
  console.log();
29
31
  console.log(chalk.dim(t().common.goodbye));
@@ -33,7 +35,7 @@ export function handleGracefulExit(err) {
33
35
  console.error(message);
34
36
  }
35
37
  else {
36
- console.error('Error occurred:', err);
38
+ console.error('An unexpected error occurred.');
37
39
  }
38
40
  process.exit(1);
39
41
  }
@@ -1,36 +1,173 @@
1
- /**
2
- * Vim keybindings support
3
- * Intercepts raw data events and replaces vim keys with terminal escape sequences
4
- * before readline processes them. This avoids patching the keypress layer which
5
- * breaks in Node.js v25+ (emitKeys generator crash).
6
- */
7
- // Maps single-byte vim keys to terminal escape sequences (ranger-style hjkl)
8
1
  const VIM_TO_SEQ = {
9
- j: Buffer.from('\u001b[B'), // down arrow
10
- k: Buffer.from('\u001b[A'), // up arrow
11
- l: Buffer.from('\r'), // enter/confirm (ranger: open/enter)
12
- g: Buffer.from('\u001b[H'), // home (first item)
13
- G: Buffer.from('\u001b[F'), // end (last item)
14
- q: Buffer.from('\u0003'), // quit
2
+ j: Buffer.from('\u001b[B'),
3
+ k: Buffer.from('\u001b[A'),
4
+ l: Buffer.from('\r'),
5
+ g: Buffer.from('\u001b[H'),
6
+ G: Buffer.from('\u001b[F'),
15
7
  };
16
8
  let vimActive = true;
17
9
  export function setVimKeysActive(active) {
18
10
  vimActive = active;
19
11
  }
12
+ function vimKeysAreActive() {
13
+ return vimActive;
14
+ }
15
+ function utf8Length(chunk, start) {
16
+ const first = chunk[start] ?? 0;
17
+ const length = first <= 0x7f
18
+ ? 1
19
+ : first >= 0xc2 && first <= 0xdf
20
+ ? 2
21
+ : first >= 0xe0 && first <= 0xef
22
+ ? 3
23
+ : first >= 0xf0 && first <= 0xf4
24
+ ? 4
25
+ : 1;
26
+ if (start + length > chunk.length)
27
+ return null;
28
+ for (let index = start + 1; index < start + length; index += 1) {
29
+ const byte = chunk[index] ?? 0;
30
+ if (byte < 0x80 || byte > 0xbf)
31
+ return 1;
32
+ }
33
+ return length;
34
+ }
35
+ function escapeLength(chunk, start) {
36
+ const introducer = chunk[start + 1];
37
+ if (introducer === undefined)
38
+ return null;
39
+ if (introducer === 0x1b || introducer <= 0x1f || introducer === 0x7f)
40
+ return 1;
41
+ if (introducer === 0x5b || introducer === 0x4f) {
42
+ for (let index = start + 2; index < chunk.length; index += 1) {
43
+ const byte = chunk[index] ?? 0;
44
+ if (byte >= 0x40 && byte <= 0x7e)
45
+ return index - start + 1;
46
+ if (byte < 0x20 || byte > 0x3f)
47
+ return 1;
48
+ }
49
+ return null;
50
+ }
51
+ if ([0x5d, 0x50, 0x5e, 0x5f].includes(introducer)) {
52
+ for (let index = start + 2; index < chunk.length; index += 1) {
53
+ if (chunk[index] === 0x07)
54
+ return index - start + 1;
55
+ if (chunk[index] === 0x1b && chunk[index + 1] === 0x5c)
56
+ return index - start + 2;
57
+ }
58
+ return null;
59
+ }
60
+ const length = utf8Length(chunk, start + 1);
61
+ return length === null ? null : length + 1;
62
+ }
63
+ function keyLength(chunk, start) {
64
+ return chunk[start] === 0x1b ? escapeLength(chunk, start) : utf8Length(chunk, start);
65
+ }
66
+ function concatBuffers(chunks) {
67
+ const size = chunks.reduce((total, chunk) => total + chunk.length, 0);
68
+ const result = Buffer.allocUnsafe(size);
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ for (const byte of chunk) {
72
+ result[offset] = byte;
73
+ offset += 1;
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+ class KeyByteFramer {
79
+ pending = Buffer.alloc(0);
80
+ get hasPending() {
81
+ return this.pending.length > 0;
82
+ }
83
+ write(chunk) {
84
+ this.pending = this.pending.length === 0 ? chunk : concatBuffers([this.pending, chunk]);
85
+ return this.drain(false);
86
+ }
87
+ flush() {
88
+ return this.drain(true);
89
+ }
90
+ takePending() {
91
+ const pending = this.pending;
92
+ this.pending = Buffer.alloc(0);
93
+ return pending;
94
+ }
95
+ drain(flush) {
96
+ const keys = [];
97
+ let offset = 0;
98
+ while (offset < this.pending.length) {
99
+ const length = keyLength(this.pending, offset);
100
+ if (length === null) {
101
+ if (flush) {
102
+ keys.push(this.pending.subarray(offset));
103
+ offset = this.pending.length;
104
+ }
105
+ break;
106
+ }
107
+ keys.push(this.pending.subarray(offset, offset + length));
108
+ offset += length;
109
+ }
110
+ this.pending = this.pending.subarray(offset);
111
+ return keys;
112
+ }
113
+ }
20
114
  export function enableVimKeys() {
21
115
  const stdin = process.stdin;
22
116
  if (!stdin.isTTY)
23
117
  return;
24
118
  const originalEmit = stdin.emit.bind(stdin);
25
- stdin.emit = function (event, ...args) {
26
- if (event === 'data' && vimActive) {
119
+ const framer = new KeyByteFramer();
120
+ let flushTimer;
121
+ const clearFlush = () => {
122
+ if (flushTimer === undefined)
123
+ return;
124
+ clearTimeout(flushTimer);
125
+ flushTimer = undefined;
126
+ };
127
+ const emitKeys = (keys) => {
128
+ let emitted = false;
129
+ for (let index = 0; index < keys.length; index += 1) {
130
+ if (!vimKeysAreActive()) {
131
+ emitted = originalEmit('data', concatBuffers(keys.slice(index))) || emitted;
132
+ return emitted;
133
+ }
134
+ const key = keys[index];
135
+ if (key === undefined)
136
+ continue;
137
+ const sequence = key.length === 1 ? VIM_TO_SEQ[String.fromCharCode(key[0] ?? 0)] : undefined;
138
+ emitted = originalEmit('data', sequence ?? key) || emitted;
139
+ }
140
+ return emitted;
141
+ };
142
+ const scheduleFlush = () => {
143
+ if (!framer.hasPending)
144
+ return;
145
+ flushTimer = setTimeout(() => {
146
+ flushTimer = undefined;
147
+ if (!vimKeysAreActive()) {
148
+ originalEmit('data', framer.takePending());
149
+ return;
150
+ }
151
+ emitKeys(framer.flush());
152
+ }, 20);
153
+ };
154
+ const translatedEmit = (event, ...args) => {
155
+ if (event === 'data') {
27
156
  const chunk = args[0];
28
- if (Buffer.isBuffer(chunk) && chunk.length === 1) {
29
- const seq = VIM_TO_SEQ[String.fromCharCode(chunk[0])];
30
- if (seq)
31
- return originalEmit('data', seq);
157
+ if (Buffer.isBuffer(chunk)) {
158
+ clearFlush();
159
+ if (!vimKeysAreActive()) {
160
+ const pending = framer.takePending();
161
+ return pending.length === 0
162
+ ? originalEmit(event, ...args)
163
+ : originalEmit('data', concatBuffers([pending, chunk]));
164
+ }
165
+ const emitted = emitKeys(framer.write(chunk));
166
+ scheduleFlush();
167
+ return emitted;
32
168
  }
33
169
  }
34
170
  return originalEmit(event, ...args);
35
171
  };
172
+ stdin.emit = translatedEmit;
36
173
  }
@@ -0,0 +1,23 @@
1
+ import chalk from 'chalk';
2
+ import { APP_INFO, URLS } from '../config/data.js';
3
+ import { note } from '../core/components/note.js';
4
+ import { pickIcon } from '../core/icons.js';
5
+ import { padEndV } from '../core/text.js';
6
+ import { t } from '../i18n/index.js';
7
+ export function showAbout() {
8
+ const trans = t();
9
+ const row = (label, value) => `${chalk.dim(padEndV(label, 12))}${value}`;
10
+ const link = (label, url) => row(label, chalk.cyan(url));
11
+ const content = [
12
+ row(trans.about.project, APP_INFO.name),
13
+ row(trans.about.version, `v${APP_INFO.version}`),
14
+ row(trans.about.description, trans.about.descriptionText),
15
+ '',
16
+ link(trans.about.github, APP_INFO.repository),
17
+ link(trans.about.website, URLS.homepage),
18
+ link(trans.about.email, URLS.email),
19
+ '',
20
+ row(trans.about.license, `MIT ${pickIcon('·', '|')} ${trans.about.author}: m1ngsama`),
21
+ ].join('\n');
22
+ note(content, trans.about.title);
23
+ }