@nbtca/prompt 1.3.2 → 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.
- package/README.md +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +24 -6
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +258 -55
- package/dist/features/links.js +7 -4
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +41 -30
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- 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.
|
package/dist/app/app.js
ADDED
|
@@ -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
|
+
}
|