@nbtca/prompt 1.4.1 → 1.5.0
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/LICENSE +1 -1
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +53 -55
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +2 -21
- package/dist/app/keys.js +10 -2
- package/dist/app/views/docs-render.js +31 -24
- package/dist/app/views/docs.js +211 -67
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +33 -76
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +47 -71
- package/dist/app/views/schedule.js +158 -90
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +93 -18
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/config/data.js +10 -13
- package/dist/config/preferences.js +14 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +41 -16
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +17 -6
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +25 -21
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -75
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -30
- package/dist/core/vim-keys.js +9 -15
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar.js +12 -185
- package/dist/features/docs.js +439 -320
- package/dist/features/schedule-render.js +65 -102
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -220
- package/dist/features/status.js +44 -59
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -37
- package/dist/i18n/index.js +87 -65
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +85 -64
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +30 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -71
- package/dist/features/links.js +0 -39
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -130
- package/dist/logo/ca-logo.png +0 -0
package/dist/core/text.js
CHANGED
|
@@ -1,101 +1,182 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 ===
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
function charWidth(ch) {
|
|
13
|
+
function codePointWidth(ch) {
|
|
13
14
|
const cp = ch.codePointAt(0) ?? 0;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
(cp >=
|
|
18
|
-
(cp >=
|
|
19
|
-
(cp >=
|
|
20
|
-
(cp >=
|
|
21
|
-
(cp >=
|
|
22
|
-
(cp >=
|
|
23
|
-
(cp >=
|
|
24
|
-
(cp >=
|
|
25
|
-
(cp >=
|
|
26
|
-
(cp >=
|
|
27
|
-
(cp >=
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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';
|
|
40
51
|
export function stripAnsi(str) {
|
|
41
|
-
return str.replace(
|
|
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();
|
|
42
60
|
}
|
|
43
|
-
/** Total visual width of a string (CJK/emoji count as 2, zero-width
|
|
44
|
-
* modifiers count as 0, ANSI codes ignored). */
|
|
45
61
|
export function visualWidth(str) {
|
|
46
62
|
const plain = stripAnsi(str);
|
|
47
63
|
let w = 0;
|
|
48
|
-
for (const
|
|
49
|
-
|
|
50
|
-
if (isZeroWidth(cp))
|
|
51
|
-
continue;
|
|
52
|
-
w += charWidth(ch);
|
|
53
|
-
}
|
|
64
|
+
for (const grapheme of graphemes(plain))
|
|
65
|
+
w += graphemeWidth(grapheme);
|
|
54
66
|
return w;
|
|
55
67
|
}
|
|
56
|
-
/** Pad string to target visual width with trailing spaces. */
|
|
57
68
|
export function padEndV(str, width) {
|
|
58
69
|
const pad = width - visualWidth(str);
|
|
59
70
|
return pad > 0 ? str + ' '.repeat(pad) : str;
|
|
60
71
|
}
|
|
61
|
-
/** Truncate to visual width limit, appending '...' if cut. */
|
|
62
72
|
export function truncate(str, maxWidth) {
|
|
63
73
|
if (visualWidth(str) <= maxWidth)
|
|
64
74
|
return str;
|
|
75
|
+
if (maxWidth <= 0)
|
|
76
|
+
return '';
|
|
77
|
+
const marker = maxWidth >= 3 ? '...' : '.'.repeat(maxWidth);
|
|
78
|
+
const available = maxWidth - visualWidth(marker);
|
|
65
79
|
let w = 0;
|
|
66
|
-
let
|
|
67
|
-
for (const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
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)
|
|
71
84
|
break;
|
|
72
85
|
w += cw;
|
|
73
|
-
|
|
86
|
+
value += grapheme;
|
|
74
87
|
}
|
|
75
|
-
return
|
|
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;
|
|
103
|
+
}
|
|
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;
|
|
76
125
|
}
|
|
77
126
|
function tokenizeForWrapping(str) {
|
|
78
127
|
const tokens = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
});
|
|
86
136
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
89
144
|
tokens.push({
|
|
90
145
|
raw,
|
|
91
|
-
width:
|
|
92
|
-
whitespace:
|
|
93
|
-
sgr:
|
|
146
|
+
width: 0,
|
|
147
|
+
whitespace: false,
|
|
148
|
+
sgr: SGR_RE.test(raw),
|
|
149
|
+
...(osc8 === undefined ? {} : { osc8 }),
|
|
94
150
|
});
|
|
95
|
-
index
|
|
151
|
+
index = start + match[0].length;
|
|
96
152
|
}
|
|
153
|
+
pushText(str.slice(index));
|
|
97
154
|
return tokens;
|
|
98
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
|
+
}
|
|
99
180
|
function advanceSgr(active, tokens, start, end) {
|
|
100
181
|
let next = active;
|
|
101
182
|
for (let index = start; index < end; index += 1) {
|
|
@@ -112,12 +193,24 @@ function advanceSgr(active, tokens, start, end) {
|
|
|
112
193
|
}
|
|
113
194
|
return next;
|
|
114
195
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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;
|
|
121
214
|
}
|
|
122
215
|
export function wrapAnsiToVisualWidth(str, maxWidth) {
|
|
123
216
|
const widthLimit = Math.max(1, Math.floor(maxWidth));
|
|
@@ -126,6 +219,7 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
|
|
|
126
219
|
const tokens = tokenizeForWrapping(str);
|
|
127
220
|
const lines = [];
|
|
128
221
|
let activeSgr = '';
|
|
222
|
+
let activeOsc8 = null;
|
|
129
223
|
let start = 0;
|
|
130
224
|
while (start < tokens.length) {
|
|
131
225
|
let width = 0;
|
|
@@ -149,7 +243,7 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
|
|
|
149
243
|
index += 1;
|
|
150
244
|
}
|
|
151
245
|
if (index >= tokens.length) {
|
|
152
|
-
lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr));
|
|
246
|
+
lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr, activeOsc8));
|
|
153
247
|
break;
|
|
154
248
|
}
|
|
155
249
|
const overflow = tokens[index];
|
|
@@ -166,9 +260,22 @@ export function wrapAnsiToVisualWidth(str, maxWidth) {
|
|
|
166
260
|
end = Math.max(index, start + 1);
|
|
167
261
|
next = end;
|
|
168
262
|
}
|
|
169
|
-
lines.push(renderWrappedSegment(tokens, start, end, activeSgr));
|
|
263
|
+
lines.push(renderWrappedSegment(tokens, start, end, activeSgr, activeOsc8));
|
|
170
264
|
activeSgr = advanceSgr(activeSgr, tokens, start, next);
|
|
265
|
+
activeOsc8 = advanceOsc8(activeOsc8, tokens, start, next);
|
|
171
266
|
start = next;
|
|
172
267
|
}
|
|
173
268
|
return lines.length > 0 ? lines : [''];
|
|
174
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
|
+
const indent = indentWidth >= width || (contentWidth > width - indentWidth && contentWidth <= width)
|
|
277
|
+
? ''
|
|
278
|
+
: preferredIndent;
|
|
279
|
+
const availableWidth = Math.max(1, width - visualWidth(indent));
|
|
280
|
+
return wrapAnsiToVisualWidth(str, availableWidth).map((line) => `${indent}${line}`);
|
|
281
|
+
}
|
package/dist/core/theme.js
CHANGED
|
@@ -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
|
};
|
package/dist/core/transitions.js
CHANGED
|
@@ -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(
|
|
12
|
+
space.indent + type.hint(glyph.rule().repeat(width)),
|
|
13
13
|
'',
|
|
14
14
|
];
|
|
15
15
|
}
|
package/dist/core/ui.js
CHANGED
|
@@ -1,46 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimalist UI component library
|
|
3
|
-
* Delegates to self-rendered widgets for terminal output
|
|
4
|
-
*/
|
|
5
1
|
import { success, error, warning, info } from './components/messages.js';
|
|
6
2
|
import { startSpinner } from './components/spinner.js';
|
|
7
3
|
import chalk from 'chalk';
|
|
8
|
-
import { pickIcon } from './icons.js';
|
|
9
4
|
import { t } from '../i18n/index.js';
|
|
5
|
+
import { sanitizeTerminalLine } from './text.js';
|
|
10
6
|
export { success, error, warning, info };
|
|
11
|
-
/**
|
|
12
|
-
* Display divider line
|
|
13
|
-
*/
|
|
14
|
-
export function printDivider() {
|
|
15
|
-
const terminalWidth = process.stdout.columns || 80;
|
|
16
|
-
const dividerChar = pickIcon('─', '-');
|
|
17
|
-
console.log(chalk.dim(dividerChar.repeat(Math.min(terminalWidth, 80))));
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Clear screen
|
|
21
|
-
*/
|
|
22
7
|
export function clearScreen() {
|
|
23
8
|
if (process.stdout.isTTY) {
|
|
24
9
|
console.clear();
|
|
25
10
|
}
|
|
26
11
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Print empty lines
|
|
29
|
-
*/
|
|
30
|
-
export function printNewLine(count = 1) {
|
|
31
|
-
for (let i = 0; i < count; i++) {
|
|
32
|
-
console.log();
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Create and start a real async spinner.
|
|
37
|
-
* Caller is responsible for calling .stop(msg) or .stop(msg, 1) on error.
|
|
38
|
-
*/
|
|
39
12
|
export function createSpinner(msg) {
|
|
40
13
|
return startSpinner(msg);
|
|
41
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
|
+
}
|
|
42
27
|
export function handleGracefulExit(err) {
|
|
43
|
-
const message =
|
|
28
|
+
const message = sanitizeTerminalLine(errorMessage(err));
|
|
44
29
|
if (message.includes('SIGINT') || message.includes('User force closed')) {
|
|
45
30
|
console.log();
|
|
46
31
|
console.log(chalk.dim(t().common.goodbye));
|
|
@@ -50,7 +35,7 @@ export function handleGracefulExit(err) {
|
|
|
50
35
|
console.error(message);
|
|
51
36
|
}
|
|
52
37
|
else {
|
|
53
|
-
console.error('
|
|
38
|
+
console.error('An unexpected error occurred.');
|
|
54
39
|
}
|
|
55
40
|
process.exit(1);
|
|
56
41
|
}
|
package/dist/core/vim-keys.js
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
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'),
|
|
10
|
-
k: Buffer.from('\u001b[A'),
|
|
11
|
-
l: Buffer.from('\r'),
|
|
12
|
-
g: Buffer.from('\u001b[H'),
|
|
13
|
-
G: Buffer.from('\u001b[F'),
|
|
14
|
-
q: Buffer.from('\u0003'),
|
|
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'),
|
|
7
|
+
q: Buffer.from('\u0003'),
|
|
15
8
|
};
|
|
16
9
|
let vimActive = true;
|
|
17
10
|
export function setVimKeysActive(active) {
|
|
@@ -22,15 +15,16 @@ export function enableVimKeys() {
|
|
|
22
15
|
if (!stdin.isTTY)
|
|
23
16
|
return;
|
|
24
17
|
const originalEmit = stdin.emit.bind(stdin);
|
|
25
|
-
|
|
18
|
+
const translatedEmit = (event, ...args) => {
|
|
26
19
|
if (event === 'data' && vimActive) {
|
|
27
20
|
const chunk = args[0];
|
|
28
21
|
if (Buffer.isBuffer(chunk) && chunk.length === 1) {
|
|
29
|
-
const seq = VIM_TO_SEQ[String.fromCharCode(chunk
|
|
22
|
+
const seq = VIM_TO_SEQ[String.fromCharCode(chunk.readUInt8(0))];
|
|
30
23
|
if (seq)
|
|
31
24
|
return originalEmit('data', seq);
|
|
32
25
|
}
|
|
33
26
|
}
|
|
34
27
|
return originalEmit(event, ...args);
|
|
35
28
|
};
|
|
29
|
+
stdin.emit = translatedEmit;
|
|
36
30
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Heatmap renderer for calendar activity.
|
|
3
|
-
* GitHub-contributions-style grid: 7 weekday rows (Mon..Sun), week columns.
|
|
4
|
-
*/
|
|
5
1
|
import chalk from 'chalk';
|
|
6
2
|
import { pickIcon } from '../core/icons.js';
|
|
7
3
|
import { space, type } from '../core/theme.js';
|
|
8
4
|
import { t, getCurrentLanguage } from '../i18n/index.js';
|
|
9
|
-
/** Parse a 'YYYY-MM-DD' date string into a UTC proxy Date (host-timezone-independent). */
|
|
10
5
|
function parseBucketDate(date) {
|
|
11
6
|
const parts = date.split('-').map(Number);
|
|
12
7
|
const y = parts[0] ?? 0;
|
|
@@ -14,11 +9,9 @@ function parseBucketDate(date) {
|
|
|
14
9
|
const d = parts[2] ?? 1;
|
|
15
10
|
return new Date(Date.UTC(y, m - 1, d));
|
|
16
11
|
}
|
|
17
|
-
/** 0=Sun,1=Mon,...,6=Sat -> Mon-indexed 0..6 */
|
|
18
12
|
function utcDayToMonIndex(utcDay) {
|
|
19
13
|
return (utcDay + 6) % 7;
|
|
20
14
|
}
|
|
21
|
-
/** Map a count to a unicode intensity glyph (or ASCII fallback). */
|
|
22
15
|
function countToGlyph(count) {
|
|
23
16
|
if (count <= 0)
|
|
24
17
|
return pickIcon('·', ' ');
|
|
@@ -30,46 +23,28 @@ function countToGlyph(count) {
|
|
|
30
23
|
return pickIcon('▓', '-');
|
|
31
24
|
return pickIcon('█', '=');
|
|
32
25
|
}
|
|
33
|
-
// A fixed 4-stop truecolor green ramp, not chalk's named green/greenBright --
|
|
34
|
-
// those resolve to whatever the user's terminal theme defines for "green",
|
|
35
|
-
// same problem the rest of the app's palette solved by specifying hex
|
|
36
|
-
// directly (theme.ts's brand blue, brandGradient) instead of leaning on
|
|
37
|
-
// ANSI color names. Kept muted/desaturated rather than a punchy grass
|
|
38
|
-
// green so it sits quietly next to the app's own cool blue palette instead
|
|
39
|
-
// of reading as a louder, disconnected accent.
|
|
40
26
|
const HEATMAP_RAMP = ['#1b4332', '#2d6a4f', '#40916c', '#52b788'];
|
|
41
27
|
const MAX_WEEK_COLUMNS = 53;
|
|
42
28
|
const GRID_PREFIX_WIDTH = 6;
|
|
43
|
-
/** Apply a green color ramp based on count. Identity when count is 0. */
|
|
44
29
|
function applyColor(glyph, count, useColor) {
|
|
45
30
|
if (!useColor || count <= 0)
|
|
46
31
|
return glyph;
|
|
47
32
|
const level = Math.min(count, HEATMAP_RAMP.length) - 1;
|
|
48
33
|
return chalk.hex(HEATMAP_RAMP[level])(glyph);
|
|
49
34
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Render a GitHub-contributions-style heatmap grid.
|
|
52
|
-
*
|
|
53
|
-
* @param buckets Dense daily buckets from nbtcal's .heatmap() call.
|
|
54
|
-
* @param today The "today" date (used to determine the end column).
|
|
55
|
-
* @param options Optional rendering options.
|
|
56
|
-
*/
|
|
57
35
|
export function renderHeatmap(buckets, today, options) {
|
|
58
36
|
const useColor = options?.color === true;
|
|
59
37
|
const trans = t();
|
|
60
|
-
const cellWidth = options?.cols !== undefined
|
|
61
|
-
&& options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
|
|
38
|
+
const cellWidth = options?.cols !== undefined && options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
|
|
62
39
|
const availableColumns = options?.cols === undefined
|
|
63
40
|
? MAX_WEEK_COLUMNS
|
|
64
41
|
: Math.floor((options.cols - GRID_PREFIX_WIDTH) / cellWidth);
|
|
65
42
|
const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns));
|
|
66
|
-
// Build a lookup map: 'YYYY-MM-DD' -> count
|
|
67
43
|
const countByDate = new Map();
|
|
68
44
|
for (const b of buckets) {
|
|
69
45
|
countByDate.set(b.date, b.count);
|
|
70
46
|
}
|
|
71
47
|
const todayProxy = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
|
72
|
-
// Find the Monday that starts the week containing today
|
|
73
48
|
const todayMonIndex = utcDayToMonIndex(todayProxy.getUTCDay());
|
|
74
49
|
const gridEndMs = todayProxy.getTime() + (6 - todayMonIndex) * 86400000; // Sunday of today's week
|
|
75
50
|
const gridStartMs = gridEndMs - (numCols * 7 - 1) * 86400000;
|
|
@@ -96,7 +71,11 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
96
71
|
columns.push(column);
|
|
97
72
|
}
|
|
98
73
|
const weekdayLabel = space.indent; // matches the grid rows' "Mo " prefix width
|
|
99
|
-
const
|
|
74
|
+
const language = getCurrentLanguage();
|
|
75
|
+
const monthFmt = new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en-US', {
|
|
76
|
+
month: 'short',
|
|
77
|
+
timeZone: 'UTC',
|
|
78
|
+
});
|
|
100
79
|
const cellsWidth = numCols * cellWidth;
|
|
101
80
|
const monthChars = new Array(cellsWidth).fill(' ');
|
|
102
81
|
let prevMonth = -1;
|
|
@@ -122,24 +101,22 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
122
101
|
}
|
|
123
102
|
}
|
|
124
103
|
const monthLabelLine = space.indent + weekdayLabel + monthChars.join('');
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
104
|
+
const weekdayNames = [
|
|
105
|
+
trans.timetable.weekdayMon.slice(0, 2),
|
|
106
|
+
' ',
|
|
107
|
+
trans.timetable.weekdayWed.slice(0, 2),
|
|
108
|
+
' ',
|
|
109
|
+
trans.timetable.weekdayFri.slice(0, 2),
|
|
110
|
+
' ',
|
|
111
|
+
' ',
|
|
112
|
+
];
|
|
131
113
|
const lines = [];
|
|
132
|
-
// Title line — same space.indent + type.heading treatment every other
|
|
133
|
-
// section heading in the app uses (this one was bare, so it sat flush
|
|
134
|
-
// against the terminal edge instead of matching the app's 3-space margin).
|
|
135
114
|
lines.push(space.indent + type.heading(trans.calendar.heatmap.title));
|
|
136
115
|
lines.push('');
|
|
137
|
-
// Month labels line
|
|
138
116
|
lines.push(monthLabelLine);
|
|
139
|
-
// Grid rows (7 rows: Mon..Sun)
|
|
140
117
|
for (let row = 0; row < 7; row++) {
|
|
141
118
|
const wdLabel = weekdayNames[row] ?? ' ';
|
|
142
|
-
const cells = columns.map(col => {
|
|
119
|
+
const cells = columns.map((col) => {
|
|
143
120
|
const cell = col[row];
|
|
144
121
|
if (cell === null || cell === undefined)
|
|
145
122
|
return ' ';
|
|
@@ -148,7 +125,6 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
148
125
|
});
|
|
149
126
|
lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`);
|
|
150
127
|
}
|
|
151
|
-
// Legend line
|
|
152
128
|
const legendGlyphs = [
|
|
153
129
|
pickIcon('·', ' '),
|
|
154
130
|
pickIcon('░', '.'),
|