@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/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,89 +1,58 @@
|
|
|
1
1
|
# NBTCA Prompt
|
|
2
2
|
|
|
3
|
-
Terminal
|
|
3
|
+
Terminal client for NBTCA events, documentation, status and personal timetables.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@nbtca/prompt)
|
|
6
6
|
[](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
|
|
15
|
+
npm install --global @nbtca/prompt
|
|
16
|
+
nbtca
|
|
12
17
|
```
|
|
13
18
|
|
|
14
|
-
|
|
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
|
-
##
|
|
25
|
+
## Commands
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
47
|
+
npm ci
|
|
48
|
+
npm run check
|
|
39
49
|
```
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
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() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
56
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
loadView('home');
|
|
193
191
|
render();
|
|
194
192
|
await done;
|
|
195
193
|
}
|
|
196
194
|
finally {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
quit();
|
|
196
|
+
await Promise.allSettled(Object.values(nativeViews).map(async (target) => {
|
|
197
|
+
await target.dispose?.();
|
|
198
|
+
}));
|
|
201
199
|
}
|
|
202
200
|
}
|
package/dist/app/chrome.js
CHANGED
|
@@ -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 +
|
|
12
|
-
|
|
13
|
-
|
|
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 +
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
103
|
-
|
|
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({
|
|
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
|
-
|
|
135
|
-
|
|
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
|
}
|