@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.
Files changed (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. package/dist/logo/ca-logo.png +0 -0
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 NingboTech University, Computer Association.
3
+ Copyright (c) 2025 NBTCA
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,89 +1,58 @@
1
1
  # NBTCA Prompt
2
2
 
3
- Terminal-based information system for NingboTech Computer Association.
3
+ Terminal client for NBTCA events, documentation, status and personal timetables.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@nbtca/prompt)](https://www.npmjs.com/package/@nbtca/prompt)
6
6
  [![License](https://img.shields.io/npm/l/@nbtca/prompt)](LICENSE)
7
7
 
8
+ ## Requirements
9
+
10
+ - Node.js 20.12 or newer
11
+
8
12
  ## Install
9
13
 
10
14
  ```bash
11
- npm install -g @nbtca/prompt
15
+ npm install --global @nbtca/prompt
16
+ nbtca
12
17
  ```
13
18
 
14
- Or run directly:
19
+ Run a command without installing:
15
20
 
16
21
  ```bash
17
- npx @nbtca/prompt
22
+ npx @nbtca/prompt --help
18
23
  ```
19
24
 
20
- ## Personal timetable
25
+ ## Commands
21
26
 
22
- Prompt owns the school login and local session; `@nbtca/nbtcal` owns timetable
23
- normalization and ICS generation.
24
-
25
- For a persistent local session:
26
-
27
- ```bash
27
+ ```text
28
+ nbtca events [--json]
29
+ nbtca docs
30
+ nbtca status
28
31
  nbtca schedule login
29
32
  nbtca schedule terms
30
33
  nbtca schedule export --term=2026:3 --week-one=YYYY-MM-DD
31
34
  nbtca schedule logout
32
35
  ```
33
36
 
34
- For a single run that neither reads nor saves a session (`--no-save` is an
35
- alias):
37
+ Use `--plain` for stable output without color. `schedule export --one-shot`
38
+ avoids reading or saving a campus session.
39
+
40
+ Prompt never stores passwords. Persisted sessions contain only a masked account
41
+ hint and a CookieJar, protected with user-only permissions on POSIX systems.
42
+ Treat exported calendars and session files as private data. See [SECURITY.md](SECURITY.md).
43
+
44
+ ## Development
36
45
 
37
46
  ```bash
38
- npx @nbtca/prompt schedule export --one-shot
47
+ npm ci
48
+ npm run check
39
49
  ```
40
50
 
41
- Both the student id and password are masked in the terminal. The password is
42
- never saved. A persistent login stores only a masked account hint and the
43
- CookieJar in the user's state directory:
44
-
45
- - Unix/macOS default: `~/.local/state/nbtca/session.json`
46
- - Windows default: `%LOCALAPPDATA%/nbtca/session.json`
47
- - `$XDG_STATE_HOME` is honored when it is an absolute path
48
-
49
- On POSIX systems the directory is `0700` and the file is `0600`. `npx`, a local
50
- installation and a global installation therefore share the same state without
51
- depending on npm's disposable package cache. The session is a bearer secret;
52
- use `--one-shot` on shared machines and `schedule logout` when finished.
53
- Saved sessions use a sliding seven-day local expiry and are cleared immediately
54
- when the school reports that they have expired.
55
-
56
- JWXT currently returns week numbers and period times but not the first calendar
57
- date of a term. When no authoritative date map is available, Prompt asks for
58
- the first teaching Monday or accepts `--week-one=YYYY-MM-DD`. Confirm it against
59
- the official school calendar; Prompt will not guess. A slider or other browser
60
- challenge is also never bypassed—the CLI stops with an actionable message.
61
- Without an authoritative date map, the result is a base teaching-week schedule:
62
- holidays, make-up classes and temporary changes still require school notices.
63
-
64
- ## Documentation
65
-
66
- Project documentation has been moved to the GitHub Wiki.
67
-
68
- - [Home](https://github.com/nbtca/Prompt/wiki)
69
- - [Getting Started](https://github.com/nbtca/Prompt/wiki/Getting-Started)
70
- - [Development Guide](https://github.com/nbtca/Prompt/wiki/Development-Guide)
71
- - [Terminal UX](https://github.com/nbtca/Prompt/wiki/Terminal-UX)
72
- - [Release Notes](https://github.com/nbtca/Prompt/wiki/Release-Notes)
73
- - [Features](https://github.com/nbtca/Prompt/wiki/Features)
74
- - [Terminal Compatibility](https://github.com/nbtca/Prompt/wiki/Terminal-Compatibility)
75
- - [FAQ](https://github.com/nbtca/Prompt/wiki/FAQ)
76
-
77
- ## Requirements
51
+ `check` runs formatting, lint, full TypeScript validation, tests, build, package
52
+ consumer checks and dependency audit.
78
53
 
79
- - Node.js >= 20.12.0
54
+ Project guides and release notes live in the [Wiki](https://github.com/nbtca/Prompt/wiki).
80
55
 
81
56
  ## License
82
57
 
83
58
  MIT
84
-
85
- ## Links
86
-
87
- - Website: https://nbtca.space
88
- - GitHub: https://github.com/nbtca
89
- - NPM: https://www.npmjs.com/package/@nbtca/prompt
package/SECURITY.md CHANGED
@@ -1,47 +1,18 @@
1
1
  # Security Policy
2
2
 
3
- ## School credentials
4
-
5
- Prompt sends the student id and an in-memory encrypted password only to the
6
- school's WebVPN/CAS login flow. It does not offer `--password`, password
7
- environment variables or credential files. Password and verification input is
8
- masked and is never written to preferences, state, cache, ICS or logs.
9
-
10
- If the school requests a slider, OTP, FIDO or another browser challenge, Prompt
11
- fails closed. It does not automate or bypass the challenge.
12
-
13
- ## Authenticated transport
14
-
15
- Authentication redirects are restricted to exact HTTPS hosts and routes for
16
- the NingboTech WebVPN, authentication service and JWXT. CAS `service` and
17
- WebVPN `origin` parameters are validated against explicit callback routes.
18
- Caller-supplied `Cookie`, `Authorization` and `Host` headers are rejected.
19
-
20
- Errors expose only stable local codes and stages. Response bodies, redirect
21
- queries, cookies, encrypted passwords and underlying network error objects are
22
- not included in user-facing output.
23
-
24
- ## Persisted session
25
-
26
- The optional persisted CookieJar is a bearer secret. It is stored under the
27
- user's state directory with a versioned schema, an atomic write, a `0700`
28
- directory and a `0600` file on POSIX systems. The saved account hint is masked,
29
- and Prompt never adds the full student id or password to the state schema. The
30
- opaque school cookies must still be treated as bearer secrets. Sessions have a
31
- sliding seven-day local expiry.
32
-
33
- Use `nbtca schedule logout` to clear it. Use `--one-shot` on a shared or
34
- untrusted computer so no session is read or written.
35
-
36
- ## Timetable and ICS privacy
37
-
38
- Raw JWXT responses are processed in memory. Only timetable fields needed for
39
- normalization are passed to `@nbtca/nbtcal`; the student profile object is
40
- discarded. Generated ICS files can reveal a person's location and routine, so
41
- they are created with mode `0600` where supported and should not be uploaded to
42
- public or "secret-link" hosting.
43
-
44
- ## Reporting vulnerabilities
45
-
46
- Use the repository's GitHub Security Advisory page. Do not include credentials,
47
- cookies, raw school responses or a personal ICS in a public issue.
3
+ Prompt sends credentials only to the allowlisted campus WebVPN and CAS login
4
+ flow. Passwords stay in memory, are masked during entry and are never accepted
5
+ through flags, environment variables or files. Interactive browser challenges
6
+ fail closed.
7
+
8
+ An optional persisted CookieJar is a bearer secret. Prompt writes it atomically
9
+ to the user's state directory with `0700` directory and `0600` file permissions
10
+ on POSIX systems. Use `nbtca schedule logout` to remove it, or `--one-shot` to
11
+ avoid reading or writing a session.
12
+
13
+ Exported calendars may reveal a person's routine and location. Keep session and
14
+ ICS files private.
15
+
16
+ Report vulnerabilities through GitHub Security Advisories. Never include
17
+ credentials, cookies, raw campus responses or personal calendars in public
18
+ issues.
package/dist/app/app.js CHANGED
@@ -1,24 +1,13 @@
1
1
  import { ansi, ensureCursorRestored } from '../core/canvas.js';
2
2
  import { composeFrame, computeBodyRows } from './frame.js';
3
3
  import { routeGlobalKey } from './keys.js';
4
- import { renderHeader, renderFooter, HEADER_LINES, FOOTER_LINES } from './chrome.js';
4
+ import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js';
5
5
  import { homeView } from './views/home.js';
6
6
  import { scheduleView } from './views/schedule.js';
7
7
  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
- /**
12
- * Event-driven full-screen app loop. Owns the alt-screen + raw-mode lifecycle
13
- * and composes every tab as a native `View` rendered in place. `ctx.runClassic`
14
- * remains as a scoped escape hatch a view can call itself when a single action
15
- * genuinely needs the real terminal (e.g. Docs handing off to glow/less to
16
- * read a file) — the app loop no longer dispatches whole tabs through it.
17
- *
18
- * Resolves once the user quits (q / Ctrl+C / Esc from home). Terminal state
19
- * (alt-screen, raw mode, cursor) is always restored before this resolves,
20
- * on SIGINT, on process exit, and even if an unexpected error is thrown.
21
- */
22
11
  export async function runApp() {
23
12
  if (!process.stdin.isTTY || !process.stdout.isTTY)
24
13
  return;
@@ -27,7 +16,6 @@ export async function runApp() {
27
16
  let running = true;
28
17
  let suspended = false;
29
18
  const viewIds = getAppTabs().map((tab) => tab.id);
30
- // Every tab is a native View rendered in place inside the alt-screen frame.
31
19
  const nativeViews = {
32
20
  home: homeView,
33
21
  schedule: scheduleView,
@@ -35,16 +23,41 @@ export async function runApp() {
35
23
  events: eventsView,
36
24
  settings: settingsView,
37
25
  };
26
+ const pendingLoads = new Map();
27
+ function loadView(id) {
28
+ const target = nativeViews[id];
29
+ if (!target?.load || pendingLoads.has(id))
30
+ return;
31
+ const pending = target
32
+ .load(ctx)
33
+ .catch(() => undefined)
34
+ .finally(() => pendingLoads.delete(id));
35
+ pendingLoads.set(id, pending);
36
+ }
38
37
  function size() {
39
38
  return { rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 };
40
39
  }
41
40
  const ctx = {
42
- get size() { return size(); },
43
- get bodyRows() { return computeBodyRows(size().rows, HEADER_LINES, FOOTER_LINES); },
44
- rerender() { render(); },
45
- resetScroll() { scroll = 0; },
46
- runClassic(fn) { return runClassic(fn); },
47
- quit() { quit(); },
41
+ get size() {
42
+ return size();
43
+ },
44
+ get bodyRows() {
45
+ const { rows } = size();
46
+ const chrome = resolveChromeLayout(rows);
47
+ return computeBodyRows(rows, chrome.headerLines, chrome.footerLines);
48
+ },
49
+ rerender() {
50
+ render();
51
+ },
52
+ resetScroll() {
53
+ scroll = 0;
54
+ },
55
+ runClassic(fn) {
56
+ return runClassic(fn);
57
+ },
58
+ quit() {
59
+ quit();
60
+ },
48
61
  };
49
62
  function render() {
50
63
  if (suspended || !running)
@@ -52,10 +65,12 @@ export async function runApp() {
52
65
  const { rows, cols } = size();
53
66
  const active = nativeViews[view];
54
67
  const tabs = getAppTabs();
55
- const header = renderHeader(tabs, view, cols);
56
- const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols));
68
+ const chrome = resolveChromeLayout(rows);
69
+ const header = renderHeader(tabs, view, cols, chrome.headerLines);
70
+ const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines);
57
71
  const body = active?.render(ctx) ?? [];
58
- process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, scroll) + ansi.eraseDown);
72
+ const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
73
+ process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, bodyScroll) + ansi.eraseDown);
59
74
  }
60
75
  function onKey(data) {
61
76
  const key = data.toString();
@@ -64,12 +79,6 @@ export async function runApp() {
64
79
  return;
65
80
  } // Ctrl-C always quits, even mid-capture.
66
81
  const active = nativeViews[view];
67
- // Esc always reaches global routing, even while a view "captures" input
68
- // for a focused field (login/search text entry). Without this carve-out,
69
- // a view whose own Esc-handling doesn't escape its captured mode would
70
- // trap the user on that tab with no way out except Ctrl-C (quitting the
71
- // whole app). Esc must never be swallowed silently — it's the universal
72
- // way out of anything.
73
82
  if (active?.capturesInput?.() && key !== '\x1b') {
74
83
  active.handleKey?.(key, ctx);
75
84
  render();
@@ -81,17 +90,13 @@ export async function runApp() {
81
90
  return;
82
91
  }
83
92
  if (g.back) {
84
- // Esc steps back one level within the view first (e.g. its week grid
85
- // back to its own hub) — only once the view has nowhere left to step
86
- // back to does Esc leave the tab for Home. Matches how k9s/lazygit
87
- // treat Esc: back one level, not straight to the root.
88
93
  if (active?.handleBack?.(ctx)) {
89
94
  scroll = 0; // the new sub-view's content height has nothing to do with the old one's
90
95
  render();
91
96
  return;
92
97
  }
93
98
  view = 'home';
94
- void nativeViews['home']?.load?.(ctx)?.catch(() => { });
99
+ loadView('home');
95
100
  render();
96
101
  return;
97
102
  }
@@ -100,9 +105,12 @@ export async function runApp() {
100
105
  return;
101
106
  }
102
107
  if (g.scrollBy) {
103
- // fitBody (frame.ts) clamps this to [0, content.length - bodyRows]
104
- // on every render regardless of what's requested here, so this never
105
- // needs to know the current body's height to stay in bounds.
108
+ if (active?.capturesPageKeys?.()) {
109
+ active.handleKey?.(key, ctx);
110
+ scroll = 0;
111
+ render();
112
+ return;
113
+ }
106
114
  const page = Math.max(1, ctx.bodyRows - 2);
107
115
  scroll = Math.max(0, scroll + g.scrollBy * page);
108
116
  render();
@@ -124,22 +132,14 @@ export async function runApp() {
124
132
  if (process.stdin.isTTY)
125
133
  process.stdin.setRawMode(false);
126
134
  process.stdout.write(ansi.showCursor + ansi.leaveAlt);
127
- // `enter()` calls `stdin.resume()`; a resumed stdin stream keeps the
128
- // Node event loop alive by design even with no listeners attached. The
129
- // classic bridge calls `enter()` again right after, so pausing here is
130
- // always safe — either it's about to be resumed, or the app is quitting
131
- // for good and this is what lets the process actually exit.
132
135
  process.stdin.pause();
133
136
  }
134
137
  function switchTo(id) {
135
138
  scroll = 0;
136
139
  view = id;
137
- void nativeViews[id]?.load?.(ctx)?.catch(() => { });
140
+ loadView(id);
138
141
  render();
139
142
  }
140
- // A classic surface (currently only Docs' glow/less pager) owns its own
141
- // raw-mode + rendering, so the app must fully leave() the alt-screen
142
- // before invoking it and re-enter() after it returns.
143
143
  async function runClassic(fn) {
144
144
  suspended = true;
145
145
  leave();
@@ -147,11 +147,10 @@ export async function runApp() {
147
147
  await fn();
148
148
  }
149
149
  catch (err) {
150
- // Classic surfaces are expected to surface their own errors, but if
151
- // one throws anyway, don't swallow it silently: leave() has already
152
- // restored cooked mode, so writing to stderr here is visible.
153
150
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
154
151
  }
152
+ if (!running)
153
+ return;
155
154
  enter();
156
155
  suspended = false;
157
156
  render();
@@ -164,9 +163,8 @@ export async function runApp() {
164
163
  }
165
164
  function onSigint() {
166
165
  quit();
167
- process.exit(0);
168
166
  }
169
- let resolveRun = () => { };
167
+ let resolveRun;
170
168
  const done = new Promise((resolve) => {
171
169
  resolveRun = resolve;
172
170
  });
@@ -189,14 +187,14 @@ export async function runApp() {
189
187
  process.once('SIGINT', onSigint);
190
188
  try {
191
189
  enter();
192
- void nativeViews['home']?.load?.(ctx)?.catch(() => { });
190
+ loadView('home');
193
191
  render();
194
192
  await done;
195
193
  }
196
194
  finally {
197
- // Safety net: if we got here via an unexpected throw rather than quit(),
198
- // make sure the terminal is restored and listeners don't leak.
199
- if (running)
200
- quit();
195
+ quit();
196
+ await Promise.allSettled(Object.values(nativeViews).map(async (target) => {
197
+ await target.dispose?.();
198
+ }));
201
199
  }
202
200
  }
@@ -2,70 +2,89 @@ import { type, space, glyph, brandMark } from '../core/theme.js';
2
2
  import { pickIcon } from '../core/icons.js';
3
3
  import { t } from '../i18n/index.js';
4
4
  import { visualWidth } from '../core/text.js';
5
- /** `renderHeader` always returns exactly this many lines (brand, tabs, rule). */
6
5
  export const HEADER_LINES = 3;
7
- /** `renderFooter` always returns exactly this many lines (rule, keyhints). */
8
6
  export const FOOTER_LINES = 2;
7
+ export function resolveChromeLayout(rows) {
8
+ const height = Math.max(0, Math.floor(rows));
9
+ if (height >= 11)
10
+ return { headerLines: 3, footerLines: 2 };
11
+ if (height >= 9)
12
+ return { headerLines: 3, footerLines: 1 };
13
+ if (height >= 7)
14
+ return { headerLines: 2, footerLines: 1 };
15
+ if (height >= 4)
16
+ return { headerLines: 1, footerLines: 1 };
17
+ if (height >= 2)
18
+ return { headerLines: 1, footerLines: 0 };
19
+ return { headerLines: 0, footerLines: 0 };
20
+ }
21
+ function renderRule(cols) {
22
+ const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : 80;
23
+ const indent = visualWidth(space.indent) < width ? space.indent : '';
24
+ const ruleWidth = Math.max(1, width - visualWidth(indent) * 2);
25
+ return indent + type.hint(glyph.rule().repeat(ruleWidth));
26
+ }
9
27
  function renderTabs(views, active, cols) {
10
28
  const dot = pickIcon('·', '-');
11
- const full = space.indent + views
12
- .map((view) => view.id === active ? type.active(`[${view.title}]`) : type.hint(view.title))
13
- .join(` ${dot} `);
29
+ const full = space.indent +
30
+ views
31
+ .map((view) => (view.id === active ? type.active(`[${view.title}]`) : type.hint(view.title)))
32
+ .join(` ${dot} `);
14
33
  if (visualWidth(full) <= cols)
15
34
  return full;
16
- const compact = space.indent + views
17
- .map((view, index) => view.id === active
18
- ? type.active(`[${index + 1} ${view.title}]`)
19
- : type.hint(String(index + 1)))
20
- .join(` ${dot} `);
35
+ const compact = space.indent +
36
+ views
37
+ .map((view, index) => view.id === active
38
+ ? type.active(`[${index + 1} ${view.title}]`)
39
+ : type.hint(String(index + 1)))
40
+ .join(` ${dot} `);
21
41
  if (visualWidth(compact) <= cols)
22
42
  return compact;
23
- return space.indent + views
24
- .map((view, index) => view.id === active
25
- ? type.active(`[${index + 1}]`)
26
- : type.hint(String(index + 1)))
27
- .join(' ');
43
+ const numeric = space.indent +
44
+ views
45
+ .map((view, index) => view.id === active ? type.active(`[${index + 1}]`) : type.hint(String(index + 1)))
46
+ .join(' ');
47
+ if (visualWidth(numeric) <= cols)
48
+ return numeric;
49
+ const activeIndex = views.findIndex((view) => view.id === active);
50
+ const activeView = views[activeIndex];
51
+ if (!activeView)
52
+ return '';
53
+ const activeWithTitle = `${space.indent}${type.active(`[${activeIndex + 1} ${activeView.title}]`)}`;
54
+ if (visualWidth(activeWithTitle) <= cols)
55
+ return activeWithTitle;
56
+ const activeNumber = `${space.indent}${type.active(`[${activeIndex + 1}]`)}`;
57
+ if (visualWidth(activeNumber) <= cols)
58
+ return activeNumber;
59
+ return type.active(String(activeIndex + 1));
28
60
  }
29
- // The header's persistent brand mark. A literal shrunk-down copy of the
30
- // emblem doesn't survive down to header height (verified: even the boldest
31
- // inner icon alone dissolves into noise below ~12 character-rows), so this
32
- // is a wordmark painted in the same gradient as the startup logo instead --
33
- // ties the two together by color, the one dimension that still reads at
34
- // one line tall, rather than attempting a shape reproduction this small
35
- // can't carry.
36
- export function renderHeader(views, active, cols) {
37
- const brand = `${space.indent}${brandMark('nbtca')}`;
61
+ export function renderHeader(views, active, cols, lineCount = HEADER_LINES) {
62
+ if (lineCount === 0)
63
+ return [];
64
+ const mark = brandMark('nbtca');
65
+ const brand = `${visualWidth(space.indent + mark) <= cols ? space.indent : ''}${mark}`;
38
66
  const tabs = renderTabs(views, active, cols);
39
- const rule = space.indent + type.hint(glyph.rule().repeat(Math.max(1, cols - 6)));
67
+ const rule = renderRule(cols);
68
+ if (lineCount === 1)
69
+ return [tabs];
70
+ if (lineCount === 2)
71
+ return [brand, tabs];
40
72
  return [brand, tabs, rule];
41
73
  }
42
- /** Shared footer hint for any view mode that captures all input (a focused
43
- * text field or a modal-like list) — the only keys that still do something
44
- * are Ctrl-C/Esc/Enter, so this is what every such view's `footerHint()`
45
- * should return instead of each re-declaring an identical string. Digits/Tab
46
- * are deliberately absent: while input is captured they're typed into the
47
- * field, not routed to global tab-switching, so promising them would itself
48
- * be the false-promise this hint exists to avoid. */
49
74
  export function fitFooterHint(cols, ...candidates) {
50
- return candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols)
51
- ?? candidates[candidates.length - 1]
52
- ?? '';
75
+ return (candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols) ??
76
+ candidates[candidates.length - 1] ??
77
+ '');
53
78
  }
54
79
  export function captureFooterHint(cols = Number.POSITIVE_INFINITY) {
55
80
  const trans = t();
56
81
  const dot = pickIcon('·', '-');
57
82
  return fitFooterHint(cols, `Ctrl+C ${trans.common.exit} ${dot} Esc ${trans.common.back} ${dot} Enter ${trans.common.confirm}`, 'Ctrl+C Esc Enter', 'Ctrl+C Esc', 'Ctrl+C');
58
83
  }
59
- /** The "1-N / Tab" tab-switch prefix, factored out so a view's own
60
- * `footerHint()` override can still include it accurately (tab count isn't
61
- * knowable inside a view module otherwise) instead of either hardcoding a
62
- * digit range that goes stale, or dropping a still-true promise entirely. */
63
84
  export function digitTabHint(tabCount) {
64
85
  const dot = pickIcon('·', '-');
65
86
  return tabCount > 1 ? `1-${tabCount} / Tab ${dot} ` : '';
66
87
  }
67
- /** Shared hint for a non-interactive state: digits/Tab still switch tabs,
68
- * while move/open do nothing and must not be advertised. */
69
88
  export function passiveFooterHint(tabCount, cols = Number.POSITIVE_INFINITY) {
70
89
  const trans = t();
71
90
  const dot = pickIcon('·', '-');
@@ -91,14 +110,12 @@ function interactiveFooterHint(tabCount, cols) {
91
110
  ];
92
111
  return fitFooterHint(cols, ...candidates);
93
112
  }
94
- /** `overrideHint`: a view supplies this (via `View.footerHint()`) when the
95
- * generic tab-switching hint would be false — e.g. while a text field has
96
- * focus, digits/Tab/q are typed characters, not shortcuts, and only Ctrl-C/
97
- * Esc/Enter actually do anything. The footer must never promise a key that
98
- * doesn't work. */
99
- export function renderFooter(_active, cols, tabCount, overrideHint) {
100
- const rule = space.indent + type.hint(glyph.rule().repeat(Math.max(1, cols - 6)));
113
+ export function renderFooter(_active, cols, tabCount, overrideHint, lineCount = FOOTER_LINES) {
114
+ if (lineCount === 0)
115
+ return [];
116
+ const rule = renderRule(cols);
101
117
  const hintText = overrideHint ?? interactiveFooterHint(tabCount, cols);
102
- const hint = space.indent + type.hint(hintText);
103
- return [rule, hint];
118
+ const indent = visualWidth(space.indent + hintText) <= cols ? space.indent : '';
119
+ const hint = indent + type.hint(hintText);
120
+ return lineCount === 1 ? [hint] : [rule, hint];
104
121
  }
@@ -3,10 +3,6 @@ import { space, type } from '../../core/theme.js';
3
3
  import { pickIcon } from '../../core/icons.js';
4
4
  import { t, fmt } from '../../i18n/index.js';
5
5
  import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
6
- /** A conservative rows-to-options budget for a ListField that fills a
7
- * view's whole body (title + blank + up to N options + an optional
8
- * more-indicator + footer). Reserves ~4 lines for that non-option chrome
9
- * so the field never itself overflows `bodyRows`. */
10
6
  export function computeMaxVisible(bodyRows) {
11
7
  return Math.max(3, bodyRows - 4);
12
8
  }
@@ -16,10 +12,6 @@ function renderIndentedOutput(value, cols) {
16
12
  const contentWidth = Math.max(1, width - visualWidth(indent));
17
13
  return wrapAnsiToVisualWidth(value, contentWidth).map((line) => `${indent}${line}`);
18
14
  }
19
- /** Non-blocking equivalent of `runMenu`: a view holds one of these in its own
20
- * state and drives it from the app loop's single stdin listener via
21
- * `handleKey`, instead of `runMenu` attaching a second listener and blocking
22
- * on a Promise. */
23
15
  export class ListField {
24
16
  config;
25
17
  index;
@@ -34,18 +26,9 @@ export class ListField {
34
26
  get selectedIndex() {
35
27
  return this.index;
36
28
  }
37
- /** How many options this field actually has — lets a caller reserve
38
- * exactly enough room for this specific menu instead of guessing a
39
- * shared constant that's wrong for every menu of a different size. */
40
29
  get optionCount() {
41
30
  return this.config.options.length;
42
31
  }
43
- /** Updates the visible-row budget in place (re-clamping the scroll window
44
- * so the selection stays visible) instead of losing the field's current
45
- * selection/scroll by rebuilding it. Views call this from their own
46
- * `render(ctx)` on every frame — cheap, and it's what keeps a field's
47
- * window in sync with the *current* terminal size even though the field
48
- * itself was constructed against whatever size was current at the time. */
49
32
  setMaxVisible(maxVisible) {
50
33
  this.maxVisible = maxVisible;
51
34
  this.clampScroll();
@@ -60,7 +43,12 @@ export class ListField {
60
43
  const { title, options, footer } = this.config;
61
44
  const maxVisible = this.maxVisible;
62
45
  if (!maxVisible || options.length <= maxVisible) {
63
- return renderMenu({ title, options, selectedIndex: this.index, footer }, cols).split('\n');
46
+ return renderMenu({
47
+ title,
48
+ options,
49
+ selectedIndex: this.index,
50
+ ...(footer === undefined ? {} : { footer }),
51
+ }, cols).split('\n');
64
52
  }
65
53
  const visible = options.slice(this.scrollTop, this.scrollTop + maxVisible);
66
54
  const lines = renderMenu({
@@ -130,17 +118,18 @@ export class ListField {
130
118
  const parsed = parseKey(key);
131
119
  if (parsed === 'cancel')
132
120
  return { cancelled: true };
133
- if (parsed === 'enter')
134
- return { selected: this.config.options[this.index]?.value };
135
- const next = nextIndex(this.index, parsed, this.config.options.length);
121
+ if (parsed === 'enter') {
122
+ const selected = this.config.options[this.index]?.value;
123
+ return selected === undefined ? {} : { selected };
124
+ }
125
+ const pageSize = Math.max(1, (this.maxVisible ?? 6) - 1);
126
+ const next = nextIndex(this.index, parsed, this.config.options.length, pageSize);
136
127
  if (next !== this.index) {
137
128
  this.index = next;
138
129
  this.clampScroll();
139
130
  }
140
131
  return {};
141
132
  }
142
- /** Keeps `index` within [scrollTop, scrollTop + maxVisible) after any move
143
- * or after maxVisible itself changes (e.g. a terminal resize). */
144
133
  clampScroll() {
145
134
  const maxVisible = this.maxVisible;
146
135
  if (!maxVisible) {
@@ -151,8 +140,6 @@ export class ListField {
151
140
  this.scrollTop = this.index;
152
141
  else if (this.index >= this.scrollTop + maxVisible)
153
142
  this.scrollTop = this.index - maxVisible + 1;
154
- // The window may also need to slide backward if it shrank enough that
155
- // scrollTop..scrollTop+maxVisible now runs past the end of the list.
156
143
  this.scrollTop = Math.max(0, Math.min(this.scrollTop, Math.max(0, this.config.options.length - maxVisible)));
157
144
  }
158
145
  }