@nbtca/prompt 1.3.1 → 1.4.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 (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +30 -13
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -111
  47. package/dist/features/docs.js +382 -128
  48. package/dist/features/links.js +7 -5
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +43 -33
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +162 -17
  58. package/dist/i18n/locales/zh.json +164 -19
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
package/README.md CHANGED
@@ -17,6 +17,50 @@ Or run directly:
17
17
  npx @nbtca/prompt
18
18
  ```
19
19
 
20
+ ## Personal timetable
21
+
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
28
+ nbtca schedule login
29
+ nbtca schedule terms
30
+ nbtca schedule export --term=2026:3 --week-one=YYYY-MM-DD
31
+ nbtca schedule logout
32
+ ```
33
+
34
+ For a single run that neither reads nor saves a session (`--no-save` is an
35
+ alias):
36
+
37
+ ```bash
38
+ npx @nbtca/prompt schedule export --one-shot
39
+ ```
40
+
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
+
20
64
  ## Documentation
21
65
 
22
66
  Project documentation has been moved to the GitHub Wiki.
package/SECURITY.md ADDED
@@ -0,0 +1,47 @@
1
+ # Security Policy
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.
@@ -0,0 +1,202 @@
1
+ import { ansi, ensureCursorRestored } from '../core/canvas.js';
2
+ import { composeFrame, computeBodyRows } from './frame.js';
3
+ import { routeGlobalKey } from './keys.js';
4
+ import { renderHeader, renderFooter, HEADER_LINES, FOOTER_LINES } from './chrome.js';
5
+ import { homeView } from './views/home.js';
6
+ import { scheduleView } from './views/schedule.js';
7
+ import { docsView } from './views/docs.js';
8
+ import { eventsView } from './views/events.js';
9
+ import { settingsView } from './views/settings.js';
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
+ export async function runApp() {
23
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
24
+ return;
25
+ let view = 'home';
26
+ let scroll = 0;
27
+ let running = true;
28
+ let suspended = false;
29
+ const viewIds = getAppTabs().map((tab) => tab.id);
30
+ // Every tab is a native View rendered in place inside the alt-screen frame.
31
+ const nativeViews = {
32
+ home: homeView,
33
+ schedule: scheduleView,
34
+ docs: docsView,
35
+ events: eventsView,
36
+ settings: settingsView,
37
+ };
38
+ function size() {
39
+ return { rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 };
40
+ }
41
+ 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(); },
48
+ };
49
+ function render() {
50
+ if (suspended || !running)
51
+ return;
52
+ const { rows, cols } = size();
53
+ const active = nativeViews[view];
54
+ const tabs = getAppTabs();
55
+ const header = renderHeader(tabs, view, cols);
56
+ const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols));
57
+ const body = active?.render(ctx) ?? [];
58
+ process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, scroll) + ansi.eraseDown);
59
+ }
60
+ function onKey(data) {
61
+ const key = data.toString();
62
+ if (key === '\x03') {
63
+ quit();
64
+ return;
65
+ } // Ctrl-C always quits, even mid-capture.
66
+ 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
+ if (active?.capturesInput?.() && key !== '\x1b') {
74
+ active.handleKey?.(key, ctx);
75
+ render();
76
+ return;
77
+ }
78
+ const g = routeGlobalKey(key, viewIds, view);
79
+ if (g.quit) {
80
+ quit();
81
+ return;
82
+ }
83
+ 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
+ if (active?.handleBack?.(ctx)) {
89
+ scroll = 0; // the new sub-view's content height has nothing to do with the old one's
90
+ render();
91
+ return;
92
+ }
93
+ view = 'home';
94
+ void nativeViews['home']?.load?.(ctx)?.catch(() => { });
95
+ render();
96
+ return;
97
+ }
98
+ if (g.switchTo) {
99
+ switchTo(g.switchTo);
100
+ return;
101
+ }
102
+ 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.
106
+ const page = Math.max(1, ctx.bodyRows - 2);
107
+ scroll = Math.max(0, scroll + g.scrollBy * page);
108
+ render();
109
+ return;
110
+ }
111
+ active?.handleKey?.(key, ctx);
112
+ render();
113
+ }
114
+ function enter() {
115
+ ensureCursorRestored();
116
+ process.stdout.write(ansi.enterAlt + ansi.hideCursor);
117
+ if (process.stdin.isTTY)
118
+ process.stdin.setRawMode(true);
119
+ process.stdin.resume();
120
+ process.stdin.on('data', onKey);
121
+ }
122
+ function leave() {
123
+ process.stdin.removeListener('data', onKey);
124
+ if (process.stdin.isTTY)
125
+ process.stdin.setRawMode(false);
126
+ 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
+ process.stdin.pause();
133
+ }
134
+ function switchTo(id) {
135
+ scroll = 0;
136
+ view = id;
137
+ void nativeViews[id]?.load?.(ctx)?.catch(() => { });
138
+ render();
139
+ }
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
+ async function runClassic(fn) {
144
+ suspended = true;
145
+ leave();
146
+ try {
147
+ await fn();
148
+ }
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
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
154
+ }
155
+ enter();
156
+ suspended = false;
157
+ render();
158
+ }
159
+ function onResize() {
160
+ render();
161
+ }
162
+ function onExit() {
163
+ leave();
164
+ }
165
+ function onSigint() {
166
+ quit();
167
+ process.exit(0);
168
+ }
169
+ let resolveRun = () => { };
170
+ const done = new Promise((resolve) => {
171
+ resolveRun = resolve;
172
+ });
173
+ function quit() {
174
+ if (!running)
175
+ return;
176
+ running = false;
177
+ try {
178
+ leave();
179
+ }
180
+ finally {
181
+ process.stdout.removeListener('resize', onResize);
182
+ process.removeListener('exit', onExit);
183
+ process.removeListener('SIGINT', onSigint);
184
+ resolveRun();
185
+ }
186
+ }
187
+ process.on('exit', onExit);
188
+ process.stdout.on('resize', onResize);
189
+ process.once('SIGINT', onSigint);
190
+ try {
191
+ enter();
192
+ void nativeViews['home']?.load?.(ctx)?.catch(() => { });
193
+ render();
194
+ await done;
195
+ }
196
+ 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();
201
+ }
202
+ }
@@ -0,0 +1,104 @@
1
+ import { type, space, glyph, brandMark } from '../core/theme.js';
2
+ import { pickIcon } from '../core/icons.js';
3
+ import { t } from '../i18n/index.js';
4
+ import { visualWidth } from '../core/text.js';
5
+ /** `renderHeader` always returns exactly this many lines (brand, tabs, rule). */
6
+ export const HEADER_LINES = 3;
7
+ /** `renderFooter` always returns exactly this many lines (rule, keyhints). */
8
+ export const FOOTER_LINES = 2;
9
+ function renderTabs(views, active, cols) {
10
+ 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} `);
14
+ if (visualWidth(full) <= cols)
15
+ 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} `);
21
+ if (visualWidth(compact) <= cols)
22
+ 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(' ');
28
+ }
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')}`;
38
+ const tabs = renderTabs(views, active, cols);
39
+ const rule = space.indent + type.hint(glyph.rule().repeat(Math.max(1, cols - 6)));
40
+ return [brand, tabs, rule];
41
+ }
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
+ export function fitFooterHint(cols, ...candidates) {
50
+ return candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols)
51
+ ?? candidates[candidates.length - 1]
52
+ ?? '';
53
+ }
54
+ export function captureFooterHint(cols = Number.POSITIVE_INFINITY) {
55
+ const trans = t();
56
+ const dot = pickIcon('·', '-');
57
+ 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
+ }
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
+ export function digitTabHint(tabCount) {
64
+ const dot = pickIcon('·', '-');
65
+ return tabCount > 1 ? `1-${tabCount} / Tab ${dot} ` : '';
66
+ }
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
+ export function passiveFooterHint(tabCount, cols = Number.POSITIVE_INFINITY) {
70
+ const trans = t();
71
+ const dot = pickIcon('·', '-');
72
+ const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : '';
73
+ return fitFooterHint(cols, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit}`, `${compactTabs}Esc ${dot} q`, `Esc ${dot} q`, 'q');
74
+ }
75
+ function interactiveFooterHint(tabCount, cols) {
76
+ const trans = t();
77
+ const dot = pickIcon('·', '-');
78
+ const fullTabs = digitTabHint(tabCount);
79
+ const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : '';
80
+ const localFull = `${trans.menu.hintMove} ${dot} ${trans.menu.hintOpen} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`;
81
+ const localCompact = `${trans.menu.hintMove} ${trans.menu.hintOpen} Esc q`;
82
+ const candidates = [
83
+ `${fullTabs}${localFull}`,
84
+ `${compactTabs}${localFull}`,
85
+ localFull,
86
+ `${compactTabs}${localCompact}`,
87
+ localCompact,
88
+ `${trans.menu.hintOpen} Esc q`,
89
+ `Esc ${dot} q`,
90
+ 'q',
91
+ ];
92
+ return fitFooterHint(cols, ...candidates);
93
+ }
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)));
101
+ const hintText = overrideHint ?? interactiveFooterHint(tabCount, cols);
102
+ const hint = space.indent + type.hint(hintText);
103
+ return [rule, hint];
104
+ }
@@ -0,0 +1,174 @@
1
+ import { renderMenu, renderMenuOption, nextIndex, parseKey, } from '../../core/components/menu.js';
2
+ import { space, type } from '../../core/theme.js';
3
+ import { pickIcon } from '../../core/icons.js';
4
+ import { t, fmt } from '../../i18n/index.js';
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
+ export function computeMaxVisible(bodyRows) {
11
+ return Math.max(3, bodyRows - 4);
12
+ }
13
+ function renderIndentedOutput(value, cols) {
14
+ const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
15
+ const indent = visualWidth(space.indent) < width ? space.indent : '';
16
+ const contentWidth = Math.max(1, width - visualWidth(indent));
17
+ return wrapAnsiToVisualWidth(value, contentWidth).map((line) => `${indent}${line}`);
18
+ }
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
+ export class ListField {
24
+ config;
25
+ index;
26
+ scrollTop = 0;
27
+ maxVisible;
28
+ constructor(config) {
29
+ this.config = config;
30
+ this.index = config.initialIndex ?? 0;
31
+ this.maxVisible = config.maxVisible;
32
+ this.clampScroll();
33
+ }
34
+ get selectedIndex() {
35
+ return this.index;
36
+ }
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
+ get optionCount() {
41
+ return this.config.options.length;
42
+ }
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
+ setMaxVisible(maxVisible) {
50
+ this.maxVisible = maxVisible;
51
+ this.clampScroll();
52
+ }
53
+ render(maxRows = Number.POSITIVE_INFINITY, cols = Number.POSITIVE_INFINITY) {
54
+ const expanded = this.renderExpanded(cols);
55
+ if (!Number.isFinite(maxRows) || expanded.length <= maxRows)
56
+ return expanded;
57
+ return this.renderCompact(Math.max(0, Math.floor(maxRows)), cols);
58
+ }
59
+ renderExpanded(cols) {
60
+ const { title, options, footer } = this.config;
61
+ const maxVisible = this.maxVisible;
62
+ if (!maxVisible || options.length <= maxVisible) {
63
+ return renderMenu({ title, options, selectedIndex: this.index, footer }, cols).split('\n');
64
+ }
65
+ const visible = options.slice(this.scrollTop, this.scrollTop + maxVisible);
66
+ const lines = renderMenu({
67
+ title,
68
+ options: visible,
69
+ selectedIndex: this.index - this.scrollTop,
70
+ }, cols).split('\n');
71
+ const above = this.scrollTop;
72
+ const below = options.length - (this.scrollTop + visible.length);
73
+ if (above > 0 || below > 0) {
74
+ const trans = t();
75
+ const parts = [
76
+ above > 0 ? fmt(trans.common.moreAbove, { count: above }) : null,
77
+ below > 0 ? fmt(trans.common.moreBelow, { count: below }) : null,
78
+ ].filter((part) => part !== null);
79
+ lines.push(...renderIndentedOutput(type.hint(parts.join(` ${pickIcon('·', '-')} `)), cols));
80
+ }
81
+ if (footer)
82
+ lines.push('', ...renderIndentedOutput(type.hint(footer), cols));
83
+ return lines;
84
+ }
85
+ renderCompact(maxRows, cols) {
86
+ if (maxRows === 0)
87
+ return [];
88
+ const { title, options } = this.config;
89
+ if (options.length === 0) {
90
+ return title ? renderIndentedOutput(type.heading(title), cols).slice(0, maxRows) : [];
91
+ }
92
+ const labelWidth = options.reduce((width, option) => Math.max(width, visualWidth(option.label)), 0);
93
+ const optionGroups = options.map((option, index) => renderMenuOption(option, index === this.index, labelWidth, cols));
94
+ const selectedLines = optionGroups[this.index] ?? [];
95
+ const titleValue = options.length > 1
96
+ ? `${type.heading(title)}${type.hint(` ${this.index + 1}/${options.length}`)}`
97
+ : type.heading(title);
98
+ const titleLines = title ? renderIndentedOutput(titleValue, cols) : [];
99
+ let header = [];
100
+ if (titleLines.length + 1 + selectedLines.length <= maxRows)
101
+ header = [...titleLines, ''];
102
+ else if (titleLines.length + selectedLines.length <= maxRows)
103
+ header = titleLines;
104
+ const optionBudget = maxRows - header.length;
105
+ if (selectedLines.length > optionBudget) {
106
+ return [...header, ...selectedLines.slice(0, optionBudget)];
107
+ }
108
+ let start = this.index;
109
+ let end = this.index + 1;
110
+ let usedRows = selectedLines.length;
111
+ const optionLimit = this.maxVisible ?? Number.POSITIVE_INFINITY;
112
+ while (end - start < optionLimit) {
113
+ const after = optionGroups[end];
114
+ if (after && usedRows + after.length <= optionBudget) {
115
+ usedRows += after.length;
116
+ end += 1;
117
+ continue;
118
+ }
119
+ const before = optionGroups[start - 1];
120
+ if (before && usedRows + before.length <= optionBudget) {
121
+ usedRows += before.length;
122
+ start -= 1;
123
+ continue;
124
+ }
125
+ break;
126
+ }
127
+ return [...header, ...optionGroups.slice(start, end).flat()];
128
+ }
129
+ handleKey(key) {
130
+ const parsed = parseKey(key);
131
+ if (parsed === 'cancel')
132
+ 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);
136
+ if (next !== this.index) {
137
+ this.index = next;
138
+ this.clampScroll();
139
+ }
140
+ return {};
141
+ }
142
+ /** Keeps `index` within [scrollTop, scrollTop + maxVisible) after any move
143
+ * or after maxVisible itself changes (e.g. a terminal resize). */
144
+ clampScroll() {
145
+ const maxVisible = this.maxVisible;
146
+ if (!maxVisible) {
147
+ this.scrollTop = 0;
148
+ return;
149
+ }
150
+ if (this.index < this.scrollTop)
151
+ this.scrollTop = this.index;
152
+ else if (this.index >= this.scrollTop + maxVisible)
153
+ 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
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, Math.max(0, this.config.options.length - maxVisible)));
157
+ }
158
+ }
159
+ export function renderListFieldWithContext(context, field, maxRows, cols = Number.POSITIVE_INFINITY) {
160
+ if (!Number.isFinite(maxRows))
161
+ return [...context, ...field.render(Number.POSITIVE_INFINITY, cols)];
162
+ const rows = Math.max(0, Math.floor(maxRows));
163
+ if (rows === 0)
164
+ return [];
165
+ const expanded = field.render(Number.POSITIVE_INFINITY, cols);
166
+ if (context.length + expanded.length <= rows)
167
+ return [...context, ...expanded];
168
+ if (expanded.length === 0)
169
+ return context.slice(0, rows);
170
+ const minimumFieldRows = Math.min(3, rows, expanded.length);
171
+ const contextRows = Math.min(context.length, rows - minimumFieldRows);
172
+ const fieldRows = rows - contextRows;
173
+ return [...context.slice(0, contextRows), ...field.render(fieldRows, cols)];
174
+ }
@@ -0,0 +1,38 @@
1
+ import { renderInput, applyInputEvent, parseInputData } from '../../core/components/text-input.js';
2
+ /** Non-blocking equivalent of `runTextInput`/`runSecretInput`: a view holds
3
+ * one of these and drives it via `handleKey` from the app loop's single
4
+ * stdin listener. Does not touch vim-key activation — the owning view is
5
+ * responsible for `setVimKeysActive(false)` while a TextField is focused
6
+ * (mirrors what `runTextInput` already does for the blocking widget). */
7
+ export class TextField {
8
+ config;
9
+ value = '';
10
+ constructor(config) {
11
+ this.config = config;
12
+ }
13
+ get currentValue() {
14
+ return this.value;
15
+ }
16
+ render(cols = Number.POSITIVE_INFINITY) {
17
+ return renderInput({
18
+ message: this.config.message,
19
+ value: this.value,
20
+ placeholder: this.config.placeholder,
21
+ secret: this.config.secret,
22
+ mask: this.config.mask,
23
+ cols,
24
+ }).split('\n');
25
+ }
26
+ handleKey(key) {
27
+ const ev = parseInputData(key);
28
+ if (ev.type === 'cancel')
29
+ return { cancelled: true };
30
+ if (ev.type === 'enter') {
31
+ if (this.value.length > 0 || this.config.allowEmpty === true)
32
+ return { submitted: this.value };
33
+ return {};
34
+ }
35
+ this.value = applyInputEvent(this.value, ev);
36
+ return {};
37
+ }
38
+ }
@@ -0,0 +1,48 @@
1
+ import { visualWidth } from '../core/text.js';
2
+ export function clipToWidth(line, cols) {
3
+ if (visualWidth(line) <= cols)
4
+ return line;
5
+ let out = '';
6
+ let w = 0;
7
+ let i = 0;
8
+ while (i < line.length) {
9
+ const esc = line.slice(i).match(/^\x1b\[[0-9;]*m/);
10
+ if (esc) {
11
+ out += esc[0];
12
+ i += esc[0].length;
13
+ continue;
14
+ }
15
+ const cp = line.codePointAt(i);
16
+ const ch = String.fromCodePoint(cp);
17
+ const cw = visualWidth(ch);
18
+ if (w + cw > cols)
19
+ break;
20
+ out += ch;
21
+ w += cw;
22
+ i += ch.length;
23
+ }
24
+ return out + '\x1b[0m';
25
+ }
26
+ export function fitLine(line, cols) {
27
+ const clipped = visualWidth(line) > cols ? clipToWidth(line, cols) : line;
28
+ const pad = cols - visualWidth(clipped);
29
+ return pad > 0 ? clipped + ' '.repeat(pad) : clipped;
30
+ }
31
+ export function fitBody(lines, height, scroll, cols) {
32
+ const maxScroll = Math.max(0, lines.length - height);
33
+ const start = Math.max(0, Math.min(scroll, maxScroll));
34
+ const out = lines.slice(start, start + height).map((l) => fitLine(l, cols));
35
+ while (out.length < height)
36
+ out.push(' '.repeat(cols));
37
+ return out;
38
+ }
39
+ export function composeFrame(header, body, footer, rows, cols, scroll) {
40
+ const h = header.map((l) => fitLine(l, cols));
41
+ const f = footer.map((l) => fitLine(l, cols));
42
+ const bodyH = Math.max(0, rows - h.length - f.length);
43
+ const b = fitBody(body, bodyH, scroll, cols);
44
+ return [...h, ...b, ...f].slice(0, rows).join('\n');
45
+ }
46
+ export function computeBodyRows(rows, headerLines, footerLines) {
47
+ return Math.max(0, rows - headerLines - footerLines);
48
+ }