@nbtca/prompt 1.5.9 → 1.5.10

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/dist/app/app.js CHANGED
@@ -8,6 +8,8 @@ import { docsView } from './views/docs.js';
8
8
  import { eventsView } from './views/events.js';
9
9
  import { settingsView } from './views/settings.js';
10
10
  import { getAppTabs } from './tabs.js';
11
+ import { renderHelp } from './help.js';
12
+ import { t } from '../i18n/index.js';
11
13
  import { SPINNER_FRAME_MS } from '../core/components/spinner.js';
12
14
  export async function runApp() {
13
15
  if (!process.stdin.isTTY || !process.stdout.isTTY)
@@ -22,6 +24,7 @@ export async function runApp() {
22
24
  let keyFlushTimer;
23
25
  let clockTimer;
24
26
  let busyTimer;
27
+ let helpOpen = false;
25
28
  let painted;
26
29
  let lastBody = { length: 0, height: 0 };
27
30
  const viewIds = getAppTabs().map((tab) => tab.id);
@@ -77,11 +80,14 @@ export async function runApp() {
77
80
  const tabs = getAppTabs();
78
81
  const chrome = resolveChromeLayout(rows);
79
82
  const header = renderHeader(tabs, view, cols, chrome.headerLines, active?.contextPath?.());
80
- const body = active?.render(ctx) ?? [];
81
- const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
83
+ const activeTab = tabs.find((tab) => tab.id === view);
84
+ const body = helpOpen
85
+ ? renderHelp(activeTab?.title ?? '', active?.shortcuts?.() ?? [], tabs.length, cols)
86
+ : (active?.render(ctx) ?? []);
87
+ const bodyScroll = !helpOpen && active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
82
88
  const height = computeBodyRows(rows, chrome.headerLines, chrome.footerLines);
83
89
  lastBody = { length: body.length, height };
84
- const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines, active?.scrollsBody?.() === true ? scrollPercent() : undefined);
90
+ const footer = renderFooter(view, cols, tabs.length, helpOpen ? t().help.close : active?.footerHint?.(tabs.length, cols), chrome.footerLines, !helpOpen && active?.scrollsBody?.() === true ? scrollPercent() : undefined);
85
91
  const lines = composeFrameLines(header, body, footer, rows, cols, bodyScroll);
86
92
  const patch = diffFrame(painted?.cols === cols ? painted.lines : undefined, lines);
87
93
  painted = { cols, lines };
@@ -127,6 +133,19 @@ export async function runApp() {
127
133
  render();
128
134
  return;
129
135
  }
136
+ if (helpOpen) {
137
+ if (key === '?' || key === '\x1b') {
138
+ helpOpen = false;
139
+ render();
140
+ }
141
+ return;
142
+ }
143
+ if (key === '?') {
144
+ helpOpen = true;
145
+ scroll = 0;
146
+ render();
147
+ return;
148
+ }
130
149
  const g = routeGlobalKey(key, viewIds, view);
131
150
  if (g.quit) {
132
151
  quit();
@@ -112,7 +112,7 @@ export function passiveFooterHint(tabCount, cols = Number.POSITIVE_INFINITY) {
112
112
  const trans = t();
113
113
  const dot = pickIcon('·', '-');
114
114
  const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : '';
115
- return fitFooterHint(cols, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit}`, `${compactTabs}Esc ${dot} q`, `Esc ${dot} q`, 'q');
115
+ return fitFooterHint(cols, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit}`, `${compactTabs}Esc ${dot} q ${dot} ?`, `Esc ${dot} q ${dot} ?`, `Esc ${dot} q`, 'q');
116
116
  }
117
117
  function interactiveFooterHint(tabCount, cols) {
118
118
  const trans = t();
@@ -122,6 +122,7 @@ function interactiveFooterHint(tabCount, cols) {
122
122
  const localFull = `${trans.menu.hintMove} ${dot} ${trans.menu.hintOpen} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`;
123
123
  const localCompact = `${trans.menu.hintMove} ${trans.menu.hintOpen} Esc q`;
124
124
  const candidates = [
125
+ `${fullTabs}${localFull} ${dot} ${trans.help.hint}`,
125
126
  `${fullTabs}${localFull}`,
126
127
  `${compactTabs}${localFull}`,
127
128
  localFull,
@@ -0,0 +1,44 @@
1
+ import { type, space, glyph } from '../core/theme.js';
2
+ import { pickIcon } from '../core/icons.js';
3
+ import { t } from '../i18n/index.js';
4
+ import { padEndV, visualWidth, wrapAnsiWithIndent } from '../core/text.js';
5
+ function row(shortcut, keyWidth, cols) {
6
+ const key = padEndV(type.label(shortcut.key), keyWidth);
7
+ const line = `${space.indent}${space.indent}${key} ${type.hint(shortcut.label)}`;
8
+ if (visualWidth(line) <= cols)
9
+ return [line];
10
+ return wrapAnsiWithIndent(`${type.label(shortcut.key)} ${type.hint(shortcut.label)}`, cols, space.indent + space.indent);
11
+ }
12
+ function group(title, shortcuts, cols) {
13
+ if (shortcuts.length === 0)
14
+ return [];
15
+ const keyWidth = shortcuts.reduce((width, item) => Math.max(width, visualWidth(item.key)), 0);
16
+ return [
17
+ ...wrapAnsiWithIndent(type.heading(title), cols, space.indent),
18
+ ...shortcuts.flatMap((shortcut) => row(shortcut, keyWidth, cols)),
19
+ '',
20
+ ];
21
+ }
22
+ export function globalShortcuts(tabCount) {
23
+ const trans = t();
24
+ const updown = glyph.updown();
25
+ return [
26
+ ...(tabCount > 1 ? [{ key: `1-${String(tabCount)}`, label: trans.help.tabs }] : []),
27
+ { key: 'Tab', label: trans.help.nextTab },
28
+ { key: `${updown} / j k`, label: trans.help.scroll },
29
+ { key: `PgUp/PgDn / ${pickIcon('␣', 'Space')}`, label: trans.help.page },
30
+ { key: 'Home/End / g G', label: trans.help.ends },
31
+ { key: pickIcon('⏎', 'Enter'), label: trans.help.open },
32
+ { key: 'Esc', label: trans.help.back },
33
+ { key: 'q', label: trans.help.quit },
34
+ ];
35
+ }
36
+ export function renderHelp(viewTitle, viewShortcuts, tabCount, cols) {
37
+ const trans = t();
38
+ return [
39
+ ...wrapAnsiWithIndent(type.heading(trans.help.title), cols, space.indent),
40
+ '',
41
+ ...group(trans.help.sectionGlobal, globalShortcuts(tabCount), cols),
42
+ ...group(viewTitle, viewShortcuts, cols),
43
+ ];
44
+ }
@@ -465,6 +465,17 @@ export const docsView = {
465
465
  return undefined;
466
466
  }
467
467
  },
468
+ shortcuts() {
469
+ const trans = t();
470
+ if (state.mode !== 'reader')
471
+ return [];
472
+ return [
473
+ ...((state.readerLinks?.length ?? 0) > 0
474
+ ? [{ key: 'f', label: trans.docs.readerLinksHint }]
475
+ : []),
476
+ { key: 'b', label: trans.docs.openBrowser },
477
+ ];
478
+ },
468
479
  scrollsBody() {
469
480
  return state.mode === 'reader' && state.readerLinksField === undefined;
470
481
  },
@@ -298,6 +298,11 @@ export const scheduleView = {
298
298
  isBusy() {
299
299
  return state.mode === 'loading';
300
300
  },
301
+ shortcuts() {
302
+ return state.mode === 'hub' && state.timetable
303
+ ? hubShortcuts(state.timetable).map(({ key, label }) => ({ key, label }))
304
+ : [];
305
+ },
301
306
  capturesInput() {
302
307
  return (state.mode === 'needsLoginId' ||
303
308
  state.mode === 'needsLoginPassword' ||
@@ -12,6 +12,8 @@ const WEBVPN_PATHS = new Set([
12
12
  '/vpn_key/update',
13
13
  ]);
14
14
  const AUTH_PATHS = new Set(['/authserver/login', '/authserver/checkNeedCaptcha.htl']);
15
+ // CAS parks accounts owing a profile here instead of issuing a ticket.
16
+ const PROFILE_GATE_PATH = '/authserver/improveInfo/improveUserInfo.do';
15
17
  const JWXT_EXACT_PATHS = new Set([
16
18
  '/sso/jziotlogin',
17
19
  '/jwglxt/ticketlogin',
@@ -92,6 +94,9 @@ export function assertAllowedCampusUrl(url) {
92
94
  }
93
95
  if (hostname === JWXT_HOST && isAllowedJwxtPath(url.pathname))
94
96
  return;
97
+ if (hostname === AUTH_HOST && url.pathname === PROFILE_GATE_PATH) {
98
+ throw new AuthError('PROFILE_INCOMPLETE', 'credentials', 'The campus requires this account to complete its profile.');
99
+ }
95
100
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service URL is not allowed.');
96
101
  }
97
102
  function safeHeaders(headers) {
@@ -88,19 +88,21 @@ function classifyRejectedLogin(html) {
88
88
  if (/锁定|冻结|次数过多|稍后再试/.test(visibleError)) {
89
89
  return new AuthError('ACCOUNT_LOCKED', 'credentials', 'The campus account is temporarily locked.');
90
90
  }
91
- if (/激活|未启用/.test(visibleError)) {
92
- return new AuthError('ACCOUNT_INACTIVE', 'credentials', 'The campus account must be activated first.');
93
- }
94
91
  if (/验证码|滑块|captcha/i.test(visibleError)) {
95
92
  return new AuthError('INTERACTIVE_CHALLENGE', 'credentials', 'The campus login requires an interactive browser challenge.');
96
93
  }
97
- if (/用户名|账号|密码|credential|password/i.test(visibleError)) {
94
+ // Key `accountLogin_account_pwd_error`; localized it also tells first-time users
95
+ // to activate, so it must be classified before the activation branch below.
96
+ if (/用户名|密码|credential|password|pwd/i.test(visibleError)) {
98
97
  return new AuthError('INVALID_CREDENTIALS', 'credentials', 'The student id or password was rejected.');
99
98
  }
99
+ if (/激活|未启用/.test(visibleError)) {
100
+ return new AuthError('ACCOUNT_INACTIVE', 'credentials', 'The campus account must be activated first.');
101
+ }
100
102
  return new AuthError('UNEXPECTED_RESPONSE', 'credentials', 'Campus login could not be confirmed.');
101
103
  }
102
- async function readText(response, stage) {
103
- if (response.status < 200 || response.status >= 300) {
104
+ async function readText(response, stage, toleratedStatus) {
105
+ if ((response.status < 200 || response.status >= 300) && response.status !== toleratedStatus) {
104
106
  throw new AuthError('HTTP_ERROR', stage, 'The campus service returned an error.', {
105
107
  retryable: response.status >= 500,
106
108
  });
@@ -220,15 +222,21 @@ export async function loginWithStudentPassword(username, password, options = {})
220
222
  method: 'POST',
221
223
  headers: {
222
224
  Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
225
+ // No Accept-Language on purpose: unresolved, the campus renders the bare
226
+ // message key, which is the only unambiguous error id this login offers.
223
227
  'Content-Type': 'application/x-www-form-urlencoded',
224
228
  Referer: loginResponse.url,
225
229
  },
226
230
  body: body.toString(),
227
231
  ...signalOption(options.signal),
228
232
  }, 'credentials');
229
- const credentialHtml = await readText(credentialResponse, 'credentials');
233
+ // A rejected login is a 401 carrying the login page; classify it, don't report HTTP_ERROR.
234
+ const credentialHtml = await readText(credentialResponse, 'credentials', 401);
230
235
  if (hasLoginFingerprint(credentialHtml))
231
236
  throw classifyRejectedLogin(credentialHtml);
237
+ if (credentialResponse.status === 401) {
238
+ throw new AuthError('HTTP_ERROR', 'credentials', 'The campus service returned an error.');
239
+ }
232
240
  await verifyJwxtSession(cookies, options.signal);
233
241
  const authenticatedAt = (options.now ?? (() => new Date()))().toISOString();
234
242
  return createAuthenticatedSession(cookies, {
package/dist/core/logo.js CHANGED
@@ -5,7 +5,7 @@ import chalk from 'chalk';
5
5
  import { useUnicodeIcons } from './icons.js';
6
6
  import { APP_INFO } from '../config/data.js';
7
7
  import { typeReveal, materializeBraille } from './motion.js';
8
- import { brandGradient as brand } from './theme.js';
8
+ import { brandGradient as brand, c } from './theme.js';
9
9
  import { visualWidth } from './text.js';
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const TAGLINE = 'To be at the intersection of technology and liberal arts.';
@@ -69,7 +69,9 @@ export async function runStartup() {
69
69
  return;
70
70
  const color = !process.env['NO_COLOR'];
71
71
  process.stdout.write('\n');
72
- await materializeBraille(art, (s) => paint(s, color));
72
+ await materializeBraille(art, (s) => paint(s, color), {
73
+ paintProgress: (s) => (color ? c.brand(s) : s),
74
+ });
73
75
  await typeReveal([
74
76
  '',
75
77
  color ? brand(TAGLINE) : TAGLINE,
@@ -83,7 +83,8 @@ export async function materializeBraille(art, paint, opts = {}) {
83
83
  }
84
84
  shown++;
85
85
  }
86
- write(paint(renderFrame()) + '\n');
86
+ const painter = f === frameCount ? paint : (opts.paintProgress ?? paint);
87
+ write(painter(renderFrame()) + '\n');
87
88
  if (f < frameCount) {
88
89
  await sleep(frameMs);
89
90
  write(ansi.cursorUp(lines.length) + ansi.cursorToCol0 + ansi.eraseDown);
@@ -59,6 +59,8 @@ export function safeMessage(error) {
59
59
  return trans.accountInactive;
60
60
  case 'INTERACTIVE_CHALLENGE':
61
61
  return trans.challenge;
62
+ case 'PROFILE_INCOMPLETE':
63
+ return trans.profileIncomplete;
62
64
  case 'SESSION_EXPIRED':
63
65
  return trans.sessionExpired;
64
66
  case 'TIMEOUT':
@@ -12,6 +12,21 @@
12
12
  "moreAbove": "{count} more above",
13
13
  "moreBelow": "{count} more below"
14
14
  },
15
+ "help": {
16
+ "title": "Keys",
17
+ "sectionGlobal": "Anywhere",
18
+ "sectionView": "Here",
19
+ "tabs": "Switch tab",
20
+ "nextTab": "Next tab",
21
+ "scroll": "Scroll a line",
22
+ "page": "Scroll a page",
23
+ "ends": "Jump to top or bottom",
24
+ "open": "Open",
25
+ "back": "Back",
26
+ "quit": "Quit",
27
+ "close": "Esc or ? to close",
28
+ "hint": "? keys"
29
+ },
15
30
  "menu": {
16
31
  "events": "Events",
17
32
  "eventsDesc": "",
@@ -218,6 +233,7 @@
218
233
  "sessionExpired": "The login session expired. Please sign in again.",
219
234
  "timeout": "The school service timed out.",
220
235
  "network": "Could not connect to the school service.",
236
+ "profileIncomplete": "The school wants this account's profile completed first; finish it in a browser, then try again.",
221
237
  "untrustedUrl": "The school returned an unapproved redirect; the request stopped safely.",
222
238
  "httpError": "The school login service returned an error status.",
223
239
  "loginChanged": "The school login page changed; credentials were not submitted.",
@@ -12,6 +12,21 @@
12
12
  "moreAbove": "上方还有 {count} 项",
13
13
  "moreBelow": "下方还有 {count} 项"
14
14
  },
15
+ "help": {
16
+ "title": "快捷键",
17
+ "sectionGlobal": "全局",
18
+ "sectionView": "当前页",
19
+ "tabs": "切换标签页",
20
+ "nextTab": "下一个标签页",
21
+ "scroll": "滚动一行",
22
+ "page": "翻页",
23
+ "ends": "跳到开头或结尾",
24
+ "open": "打开",
25
+ "back": "返回",
26
+ "quit": "退出",
27
+ "close": "Esc 或 ? 关闭",
28
+ "hint": "? 快捷键"
29
+ },
15
30
  "menu": {
16
31
  "events": "活动",
17
32
  "eventsDesc": "",
@@ -218,6 +233,7 @@
218
233
  "sessionExpired": "登录状态已过期,请重新登录。",
219
234
  "timeout": "学校服务响应超时。",
220
235
  "network": "无法连接学校服务。",
236
+ "profileIncomplete": "学校要求先补全个人资料;请在浏览器登录统一身份认证完成后再试。",
221
237
  "untrustedUrl": "学校返回了未授权的登录跳转,已安全中止。",
222
238
  "httpError": "学校登录服务返回了错误状态。",
223
239
  "loginChanged": "学校登录页结构已经变化,当前版本未提交凭据。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.5.9",
3
+ "version": "1.5.10",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -68,7 +68,7 @@
68
68
  "typescript": "^5.9.3",
69
69
  "typescript-eslint": "^8.67.0",
70
70
  "vite": "^6.4.3",
71
- "vitest": "^3.2.7"
71
+ "vitest": "^4.1.11"
72
72
  },
73
73
  "engines": {
74
74
  "node": ">=20.12.0"